M3: 섯다·포커 규칙과 화면, 카드 게임 E2E
- 섯다(packages/games/seotda): 2장·3장 섯다, 1장씩/한 번에 나눠 주기, 족보(광땡·땡·알리…망통), 땡잡이·암행어사·구사/멍텅구리구사 재경기, 동점 재경기·나눔, 리바이·탈락, 사이드 팟. 규칙 테스트 54개 - 포커(packages/games/poker): 홀덤·오마하·7포커(초이스·보스)·7포커 하이로우(동시 선언)·바둑이(아침·점심·저녁)· 5카드 드로우·인디언 포커(이마 카드, 동점 이월, 다이 벌칙). 한국식/노리밋/팟리밋, 블라인드·앤티, 리바이·레벨 상승·최후 1인. 규칙 테스트 52개 + 족보 판정 24개, 무작위 1,000판(칩 보존·비공개 카드 유출 없음) - 웹: 섯다·포커 화면(테이블 좌석, 공용 카드, 사이드 팟, 한국식 버튼/레이즈 슬라이더, 초이스·교환·선언, 체크 가능할 때 포기 확인, 리바이 안내), 섯다 결과 화면에서 가져간 칩 표시 - 엔진 betting.buildPots: 라운드 진행 중에는 아직 콜할 수 있는 플레이어도 각 팟 자격자로 표시 - 서버 테스트: 크래시 흉내 후 소켓 종료로 다시 걸린 타이머가 닫힌 DB를 건드리던 누수 수정 - E2E: 3인(PC + 휴대폰 2) 섯다·7포커 한 판, 6개 게임 모두 통과 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
990
apps/web/src/games/poker/index.tsx
Normal file
990
apps/web/src/games/poker/index.tsx
Normal file
@@ -0,0 +1,990 @@
|
||||
/** 포커 화면 (docs/games/poker.md §9). Chips are room-only play points. */
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { BetActionType, LegalBetView, PokerMode, PokerOptions, PokerView, SeatView, Side, ViewCard } from '@bg/games/poker';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { Avatar, ConfirmSheet, Sheet } from '../../components/ui';
|
||||
import { TurnTimer } from '../../components/TurnTimer';
|
||||
import { PlayingCard } from '../../components/cards/PlayingCard';
|
||||
import { Chips, PlayChipNotice, formatChips } from '../../components/cards/Chips';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
|
||||
const MODE_KO: Record<PokerMode, string> = {
|
||||
holdem: '텍사스 홀덤',
|
||||
omaha: '오마하',
|
||||
sevenPoker: '7포커',
|
||||
sevenHiLo: '7포커 하이로우',
|
||||
badugi: '바둑이',
|
||||
fiveDraw: '5카드 드로우',
|
||||
indian: '인디언 포커',
|
||||
};
|
||||
const KOREAN_ORDER: BetActionType[] = ['k.die', 'k.check', 'k.ping', 'k.call', 'k.ddadang', 'k.quarter', 'k.half', 'k.full'];
|
||||
const BET_KO: Record<BetActionType, string> = {
|
||||
'k.check': '체크',
|
||||
'k.ping': '삥',
|
||||
'k.call': '콜',
|
||||
'k.ddadang': '따당',
|
||||
'k.quarter': '쿼터',
|
||||
'k.half': '하프',
|
||||
'k.full': '풀',
|
||||
'k.die': '다이',
|
||||
'nl.fold': '폴드',
|
||||
'nl.check': '체크',
|
||||
'nl.call': '콜',
|
||||
'nl.raise': '레이즈',
|
||||
'nl.allIn': '올인',
|
||||
};
|
||||
const BET_HELP: Partial<Record<BetActionType, string>> = {
|
||||
'k.check': '칩을 걸지 않고 넘겨요',
|
||||
'k.ping': '기본 칩만큼 걸어요',
|
||||
'k.call': '앞사람만큼 맞춰요',
|
||||
'k.ddadang': '앞사람이 건 만큼의 두 배',
|
||||
'k.quarter': '모인 칩의 4분의 1만큼 더',
|
||||
'k.half': '모인 칩의 절반만큼 더',
|
||||
'k.full': '모인 칩만큼 더',
|
||||
'k.die': '이번 판을 포기해요',
|
||||
};
|
||||
const SIDE_KO: Record<Side, string> = { high: '하이', low: '로우', swing: '스윙' };
|
||||
const AWARD_KO = { high: '하이', low: '로우', scoop: '스윙 성공', carry: '넘어온 칩', penalty: '벌칙 칩', refund: '돌려받음' } as const;
|
||||
|
||||
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 isCommunity = (m: PokerMode) => m === 'holdem' || m === 'omaha';
|
||||
const isSevenMode = (m: PokerMode) => m === 'sevenPoker' || m === 'sevenHiLo';
|
||||
const isDrawMode = (m: PokerMode) => m === 'badugi' || m === 'fiveDraw';
|
||||
/** Rank of a card id for the client-side "A를 남기면 4장" check. */
|
||||
const isAce = (c: string) => c[0] === 'A';
|
||||
|
||||
const CSS = `
|
||||
.pk-table { position: relative; width: 100%; aspect-ratio: 16 / 11; border-radius: 180px; border: 10px solid var(--wood);
|
||||
background: radial-gradient(ellipse at center, rgba(255,255,255,0.12) 0%, rgba(255,255,255,0) 60%), var(--felt); box-shadow: var(--shadow); }
|
||||
.pk-seat { position: absolute; left: var(--x); top: var(--y); transform: translate(-50%, -50%); width: var(--seat-w, 172px); z-index: 1;
|
||||
background: var(--surface); color: var(--text); border-radius: 12px; padding: 6px 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.25);
|
||||
border: 3px solid transparent; display: flex; flex-direction: column; gap: 4px; font-size: 0.85rem; transition: border-color .2s; }
|
||||
.pk-seat.turn { border-color: var(--focus); }
|
||||
.pk-seat.me { border-color: var(--primary); }
|
||||
.pk-seat.me.turn { border-color: var(--focus); }
|
||||
.pk-seat.win { border-color: #f59e0b; }
|
||||
.pk-seat.out { opacity: 0.6; }
|
||||
.pk-center { position: absolute; left: 50%; top: 48%; transform: translate(-50%, -50%); text-align: center; color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,.5); max-width: 52%; }
|
||||
.pk-board { display: flex; gap: 6px; justify-content: center; margin: 6px 0; }
|
||||
.pk-slot { width: 64px; height: 90px; border-radius: 8px; border: 2px dashed rgba(255,255,255,.35); flex: none; }
|
||||
.pk-actions { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
|
||||
.pk-actions .btn { min-height: 64px; flex-direction: column; gap: 0; padding: 0 8px; line-height: 1.15; font-size: 1.1rem; }
|
||||
.pk-actions .btn small { font-weight: 600; font-size: .85rem; opacity: .9; }
|
||||
.pk-mine { display: flex; gap: 4px; align-items: flex-end; flex-wrap: wrap; justify-content: center; }
|
||||
.pk-badge { display: inline-block; border-radius: 999px; padding: 0 8px; font-weight: 800; font-size: .78rem; }
|
||||
.pk-x { position: relative; }
|
||||
.pk-x::after { content: '✕'; position: absolute; inset: 0; display: grid; place-items: center; color: #dc2626; font-size: 3rem; font-weight: 900; pointer-events: none; }
|
||||
.pk-raise { display: grid; gap: 8px; }
|
||||
.pk-raise input[type=range] { width: 100%; min-height: 40px; }
|
||||
@media (max-width: 719px) {
|
||||
.pk-table { aspect-ratio: auto; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 10px; border-radius: 20px; border-width: 6px; }
|
||||
.pk-seat { position: static; transform: none; width: auto; }
|
||||
.pk-center { position: static; transform: none; grid-column: 1 / -1; order: -1; max-width: none; padding: 8px 0; }
|
||||
.pk-slot { width: 44px; height: 62px; }
|
||||
.pk-actions { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------- seats
|
||||
|
||||
function seatBadges(seat: SeatView, v: PokerView): [string, string][] {
|
||||
const b: [string, string][] = [];
|
||||
if (seat.status === 'left') b.push(['나감', 'var(--danger)']);
|
||||
else if (seat.away) b.push(['자리 비움', 'var(--warning)']);
|
||||
if (seat.status === 'eliminated') b.push(['탈락', 'var(--text-sub)']);
|
||||
if (seat.status === 'busted' || seat.rebuyPending) b.push(['리바이 대기', 'var(--warning)']);
|
||||
if (seat.folded) b.push([v.betStyle === 'korean' ? '다이' : '폴드', 'var(--danger)']);
|
||||
if (seat.allIn) b.push(['올인', 'var(--primary)']);
|
||||
const contesting = seat.inHand && !seat.folded;
|
||||
if (contesting && v.handPhase === 'choice') b.push(seat.choiceDone ? ['고름', 'var(--success)'] : ['고르는 중', 'var(--text-sub)']);
|
||||
if (contesting && v.handPhase === 'draw') b.push(seat.drawDone ? ['냄', 'var(--success)'] : ['바꿀 카드 고르는 중', 'var(--text-sub)']);
|
||||
if (contesting && v.handPhase === 'declare') b.push(seat.declared ? ['선언함', 'var(--success)'] : ['선언 고르는 중', 'var(--text-sub)']);
|
||||
if (seat.declare) b.push([`선언: ${SIDE_KO[seat.declare]}`, '#7c3aed']);
|
||||
return b;
|
||||
}
|
||||
|
||||
function SeatCards({ seat, v, me }: { seat: SeatView; v: PokerView; me: string | null }) {
|
||||
const sd = v.showdown?.find((e) => e.id === seat.id);
|
||||
const cards: { c: ViewCard; key: string; title?: string }[] = [];
|
||||
if (seat.forehead !== undefined) cards.push({ c: seat.forehead, key: 'f', title: seat.forehead ? undefined : seat.id === me ? '내 이마 카드(나만 못 봐요)' : '이마 카드' });
|
||||
// Hidden cards arrive as null; at showdown the view already reveals them.
|
||||
seat.hole.forEach((c, i) => cards.push({ c, key: `h${i}` }));
|
||||
for (let i = 0; i < seat.choiceCount; i++) cards.push({ c: null, key: `c${i}` });
|
||||
seat.up.forEach((c, i) => cards.push({ c, key: `u${i}` }));
|
||||
if (!cards.length) return null;
|
||||
const best = new Set(sd?.best ?? []);
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'nowrap', justifyContent: 'center' }}>
|
||||
{cards.map((x, i) => (
|
||||
<PlayingCard
|
||||
key={x.key}
|
||||
card={x.c}
|
||||
size="sm"
|
||||
highlight={!!x.c && best.has(x.c)}
|
||||
title={x.title}
|
||||
style={i > 0 ? { marginLeft: cards.length > 5 ? -24 : -16 } : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SeatBox({ seat, v, players, me, deadline, x, y }: { seat: SeatView; v: PokerView; players: Record<string, PublicUser>; me: string | null; deadline: number | null; x: number; y: number }) {
|
||||
const u = userOf(players, seat.id);
|
||||
const badges = seatBadges(seat, v);
|
||||
const waiting = v.waiting.includes(seat.id) && v.handPhase !== 'handEnd';
|
||||
const out = seat.status === 'eliminated' || seat.folded || (!seat.inHand && v.handPhase !== null);
|
||||
const won = v.handPhase === 'handEnd' && v.winners.includes(seat.id);
|
||||
const sd = v.showdown?.find((e) => e.id === seat.id);
|
||||
const marks: [string, string, string][] = [];
|
||||
if (seat.dealer) marks.push(['D', '#facc15', '딜러']);
|
||||
if (seat.sb) marks.push(['SB', '#93c5fd', '스몰 블라인드']);
|
||||
if (seat.bb) marks.push(['BB', '#60a5fa', '빅 블라인드']);
|
||||
if (seat.boss) marks.push(['보스', '#fb923c', '보스(먼저 베팅)']);
|
||||
return (
|
||||
<div
|
||||
className={`pk-seat${waiting ? ' turn' : ''}${seat.id === me ? ' me' : ''}${won ? ' win' : ''}${out ? ' out' : ''}`}
|
||||
style={{ '--x': `${x}%`, '--y': `${y}%` } as React.CSSProperties}
|
||||
aria-label={`${u.nickname} ${formatChips(seat.stack)}${marks.length ? `, ${marks.map((m) => m[2]).join(', ')}` : ''}${badges.length ? `, ${badges.map((b) => b[0]).join(', ')}` : ''}`}
|
||||
>
|
||||
<div className="row" style={{ gap: 5, flexWrap: 'nowrap' }}>
|
||||
<Avatar user={u} size={22} />
|
||||
<strong style={{ flex: 1, minWidth: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{u.nickname}
|
||||
{seat.id === me && <span className="muted"> (나)</span>}
|
||||
</strong>
|
||||
{marks.map(([t, c, title]) => (
|
||||
<span key={t} className="pk-badge" style={{ background: c, color: '#1f2937' }} title={title}>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
{waiting && <TurnTimer deadline={deadline} totalMs={v.deadlineTotalMs} mine={seat.id === me} size={28} />}
|
||||
</div>
|
||||
<div className="row" style={{ gap: 6, justifyContent: 'space-between' }}>
|
||||
<Chips n={seat.stack} strong />
|
||||
{seat.roundBet > 0 && <span className="small" title="이번 라운드에 건 칩">+{seat.roundBet.toLocaleString('ko-KR')}</span>}
|
||||
</div>
|
||||
{seat.lastAction && !seat.folded && <span className="small muted">{seat.lastAction}</span>}
|
||||
{seat.drawCounts.length > 0 && <span className="small muted">바꾼 장수: {seat.drawCounts.join(' · ')}</span>}
|
||||
{badges.length > 0 && (
|
||||
<div className="row" style={{ gap: 3 }}>
|
||||
{badges.map(([t, c]) => (
|
||||
<span key={t} className="pk-badge" style={{ background: c, color: '#fff' }}>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<SeatCards seat={seat} v={v} me={me} />
|
||||
{sd && (
|
||||
<strong style={{ color: 'var(--primary)', textAlign: 'center' }}>
|
||||
{sd.handName}
|
||||
{sd.lowName !== undefined && <span className="small"> / 로우 {sd.lowName ?? '없음'}</span>}
|
||||
</strong>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CenterInfo({ v, players }: { v: PokerView; players: Record<string, PublicUser> }) {
|
||||
const limit = v.options.endCondition === 'hands' ? `/${v.options.handLimit}` : '';
|
||||
const penalty = v.penalties.reduce((a, p) => a + p.amount, 0);
|
||||
return (
|
||||
<div className="pk-center">
|
||||
{v.streetName && <div className="pk-badge" style={{ background: '#fde047', color: '#422006', fontSize: '0.95rem', padding: '2px 12px' }}>{v.streetName}</div>}
|
||||
{isCommunity(v.mode) && (
|
||||
<div className="pk-board" aria-label="공용 카드">
|
||||
{Array.from({ length: 5 }, (_, i) => (v.board[i] ? <PlayingCard key={v.board[i]} card={v.board[i]!} size="md" /> : <span key={`e${i}`} className="pk-slot" aria-hidden />))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: '0.95rem', opacity: 0.9 }}>모인 칩</div>
|
||||
<div style={{ fontSize: '2.2rem', fontWeight: 900, fontVariantNumeric: 'tabular-nums' }} aria-live="polite">
|
||||
{formatChips(v.pot)}
|
||||
</div>
|
||||
{v.pots.length > 1 && (
|
||||
<div className="small" style={{ opacity: 0.9 }}>
|
||||
{v.pots.map((p, i) => (
|
||||
<div key={i}>
|
||||
{i === 0 ? '메인' : `사이드 ${i}`}: {formatChips(p.amount)} ({p.eligible.map((id) => nick(players, id)).join('·')})
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(v.carryOver > 0 || penalty > 0) && (
|
||||
<div className="small" style={{ opacity: 0.9 }}>
|
||||
{v.carryOver > 0 && `지난 판에서 넘어온 칩 ${formatChips(v.carryOver)}`}
|
||||
{v.carryOver > 0 && penalty > 0 && ' · '}
|
||||
{penalty > 0 && `벌칙 칩 ${formatChips(penalty)}`}
|
||||
</div>
|
||||
)}
|
||||
<div className="small" style={{ opacity: 0.85, marginTop: 4 }}>
|
||||
{v.phase === 'finished' ? '게임 끝' : `${Math.max(1, v.handNo)}${limit}판`} · 기본 {formatChips(v.baseUnit)}
|
||||
{v.level > 1 ? ` (레벨 ${v.level})` : ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- board
|
||||
|
||||
function PokerBoard({ view: v, me, players, act, pending, deadline }: BoardProps<PokerView>) {
|
||||
const n = v.seats.length;
|
||||
const myIdx = Math.max(0, v.seats.findIndex((s) => s.id === me));
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const prevWaiting = useRef(false);
|
||||
const mine = isMyTurn(v, me);
|
||||
const showKey = `${v.handNo}-${v.handPhase}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (mine && !prevWaiting.current) {
|
||||
haptic(40);
|
||||
sounds.myTurn();
|
||||
setAnnounce('내 차례예요.');
|
||||
} else if (v.toAct && v.toAct !== me) setAnnounce(`${nick(players, v.toAct)}님 차례예요.`);
|
||||
prevWaiting.current = mine;
|
||||
}, [mine, v.toAct]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (v.handPhase !== 'handEnd') return;
|
||||
setAnnounce(resultLine(v, players));
|
||||
if (me && v.winners.includes(me)) sounds.win();
|
||||
}, [showKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div className="board-wrap stack" style={{ gap: 12 }}>
|
||||
<style>{CSS}</style>
|
||||
<div className="pk-table" role="region" aria-label="포커 테이블" style={{ '--seat-w': n > 6 ? '150px' : '176px' } as React.CSSProperties}>
|
||||
{v.seats.map((s, i) => {
|
||||
const rel = (i - myIdx + n) % n;
|
||||
const ang = Math.PI / 2 + (rel * 2 * Math.PI) / n;
|
||||
return <SeatBox key={s.id} seat={s} v={v} players={players} me={me} deadline={deadline} x={50 + 40 * Math.cos(ang)} y={50 + 38 * Math.sin(ang)} />;
|
||||
})}
|
||||
<CenterInfo v={v} players={players} />
|
||||
</div>
|
||||
<MyArea v={v} me={me} players={players} act={act} pending={pending} />
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function resultLine(v: PokerView, players: Record<string, PublicUser>): string {
|
||||
if (!v.awards) return '';
|
||||
const won = new Map<string, number>();
|
||||
for (const a of v.awards) if (a.side !== 'refund') won.set(a.to, (won.get(a.to) ?? 0) + a.amount);
|
||||
if (!won.size) return v.carryOver > 0 ? `동점이라 ${formatChips(v.carryOver)}이 다음 판으로 넘어가요` : '이번 판은 칩을 모두 돌려받았어요';
|
||||
const parts = [...won].map(([id, amt]) => {
|
||||
const name = v.showdown?.find((e) => e.id === id)?.handName;
|
||||
return `${nick(players, id)}님이 ${name ? `${name}(으)로 ` : ''}${formatChips(amt)}을 가져갔어요`;
|
||||
});
|
||||
return parts.join(' / ') + (v.foldWin ? ' (모두 포기)' : '');
|
||||
}
|
||||
|
||||
function ResultPanel({ v, players }: { v: PokerView; players: Record<string, PublicUser> }) {
|
||||
if (!v.awards) return null;
|
||||
return (
|
||||
<div className="card stack" style={{ gap: 6, padding: 12 }} aria-live="polite">
|
||||
<strong>이번 판 결과</strong>
|
||||
{v.foldWin && <span className="small muted">한 명만 남아서 패는 공개하지 않아요.</span>}
|
||||
{v.awards.map((a, i) => {
|
||||
const sd = v.showdown?.find((e) => e.id === a.to);
|
||||
const hand = !sd ? '' : a.side === 'low' ? (sd.lowName ? `로우 ${sd.lowName}` : '') : !a.side || a.side === 'high' || a.side === 'scoop' ? sd.handName : '';
|
||||
return (
|
||||
<span key={i}>
|
||||
<strong>{nick(players, a.to)}</strong> {formatChips(a.amount)}
|
||||
{hand && ` · ${hand}`}
|
||||
<span className="muted">
|
||||
{v.awards!.some((x) => x.potIndex > 0) ? ` · ${a.potIndex === 0 ? '메인' : `사이드 ${a.potIndex}`}` : ''}
|
||||
{a.side ? ` · ${AWARD_KO[a.side]}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{v.awards.every((a) => a.side === 'refund') && v.carryOver > 0 && <span>동점이라 {formatChips(v.carryOver)}이 다음 판으로 넘어가요.</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- my area
|
||||
|
||||
interface AreaProps {
|
||||
v: PokerView;
|
||||
me: string | null;
|
||||
players: Record<string, PublicUser>;
|
||||
act: (a: unknown) => void;
|
||||
pending: boolean;
|
||||
}
|
||||
|
||||
function MyArea({ v, me, players, act, pending }: AreaProps) {
|
||||
const seat = v.seats.find((s) => s.id === me);
|
||||
if (!me || !seat) return v.handPhase === 'handEnd' ? <ResultPanel v={v} players={players} /> : null;
|
||||
const m = v.my;
|
||||
return (
|
||||
<div className="stack" style={{ gap: 10 }}>
|
||||
{seat.inHand && (
|
||||
<div className="card stack" style={{ gap: 10, padding: 12 }}>
|
||||
{m.canChoose && m.choice ? (
|
||||
<ChoicePicker key={v.handNo} cards={m.choice} act={act} pending={pending} />
|
||||
) : m.canDraw ? (
|
||||
<DrawPicker key={`${v.handNo}-${seat.drawCounts.length}`} v={v} hole={seat.hole.filter((c): c is string => !!c)} act={act} pending={pending} />
|
||||
) : (
|
||||
<MyCards v={v} seat={seat} />
|
||||
)}
|
||||
{(m.handName || m.lowName) && (
|
||||
<div style={{ textAlign: 'center', fontSize: '1.4rem', fontWeight: 900 }}>
|
||||
지금: {m.handName}
|
||||
{m.lowName && <span className="small" style={{ marginLeft: 8 }}>로우 {m.lowName}</span>}
|
||||
</div>
|
||||
)}
|
||||
{seat.folded && <p className="small muted" style={{ margin: 0, textAlign: 'center' }}>이번 판은 포기했어요. 내 카드는 나만 볼 수 있어요.</p>}
|
||||
{v.handPhase === 'choice' && seat.choiceDone && <p className="small muted" style={{ margin: 0, textAlign: 'center' }}>골랐어요. 다른 사람을 기다리는 중이에요.</p>}
|
||||
{v.handPhase === 'draw' && seat.drawDone && <p className="small muted" style={{ margin: 0, textAlign: 'center' }}>바꿀 카드를 냈어요. 다른 사람을 기다리는 중이에요.</p>}
|
||||
{m.canDeclare && <DeclarePicker v={v} act={act} pending={pending} />}
|
||||
{v.handPhase === 'declare' && seat.declared && !m.canDeclare && (
|
||||
<p className="small muted" style={{ margin: 0, textAlign: 'center' }}>
|
||||
{m.declare ? `${SIDE_KO[m.declare]}(으)로 선언했어요. ` : ''}다른 사람을 기다리는 중이에요.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{seat.inHand && v.handPhase === 'betting' && !seat.folded && !seat.allIn && <BetBar v={v} me={me} players={players} act={act} pending={pending} />}
|
||||
{v.handPhase === 'handEnd' && <ResultPanel v={v} players={players} />}
|
||||
{m.canReady && (
|
||||
<button type="button" className="btn btn-primary btn-block" disabled={pending} onClick={() => act({ type: 'ready' })}>
|
||||
다음 판으로
|
||||
</button>
|
||||
)}
|
||||
{v.handPhase === 'handEnd' && seat.inHand && seat.ready && seat.status !== 'left' && <p className="small muted" style={{ margin: 0, textAlign: 'center' }}>다른 사람을 기다리는 중…</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MyCards({ v, seat }: { v: PokerView; seat: SeatView }) {
|
||||
const sd = v.showdown?.find((e) => e.id === seat.id);
|
||||
const best = new Set(sd?.best ?? []);
|
||||
return (
|
||||
<div className="pk-mine" aria-label="내 카드">
|
||||
{seat.forehead !== undefined && (
|
||||
<div className="stack" style={{ gap: 2, alignItems: 'center' }}>
|
||||
<PlayingCard card={seat.forehead} size="lg" title={seat.forehead ? undefined : '내 이마 카드(나만 못 봐요)'} />
|
||||
<span className="small muted">이마</span>
|
||||
</div>
|
||||
)}
|
||||
{seat.hole.map((c, i) => (
|
||||
<div key={`h${i}`} className="stack" style={{ gap: 2, alignItems: 'center' }}>
|
||||
<PlayingCard card={c} size="lg" highlight={!!c && best.has(c)} />
|
||||
{(isSevenMode(v.mode) || v.mode === 'indian') && <span className="small muted">{v.mode === 'indian' ? '손' : '나만 봄'}</span>}
|
||||
</div>
|
||||
))}
|
||||
{seat.up.map((c, i) => (
|
||||
<div key={`u${i}`} className="stack" style={{ gap: 2, alignItems: 'center' }}>
|
||||
<PlayingCard card={c} size="lg" highlight={!!c && best.has(c)} />
|
||||
<span className="small muted">공개</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 7포커 초이스: 1) 버릴 카드 2) 보여 줄 카드 → 확인 (§9). */
|
||||
function ChoicePicker({ cards, act, pending }: { cards: string[]; act: (a: unknown) => void; pending: boolean }) {
|
||||
const [discard, setDiscard] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
const tap = (c: string) => {
|
||||
if (!discard) setDiscard(c);
|
||||
else if (c === discard) {
|
||||
setDiscard(null);
|
||||
setOpen(null);
|
||||
} else setOpen(open === c ? null : c);
|
||||
};
|
||||
return (
|
||||
<div className="stack" style={{ gap: 8 }}>
|
||||
<p style={{ margin: 0, textAlign: 'center' }}>
|
||||
<strong>{!discard ? '1단계: 버릴 카드를 누르세요' : !open ? '2단계: 모두에게 보여 줄 카드를 누르세요' : '확인을 누르면 정해져요'}</strong>
|
||||
<br />
|
||||
<span className="small muted">버린 카드는 아무에게도 보이지 않아요. 남는 2장은 나만 봐요.</span>
|
||||
</p>
|
||||
<div className="pk-mine">
|
||||
{cards.map((c) => (
|
||||
<span key={c} className={c === discard ? 'pk-x' : undefined}>
|
||||
<PlayingCard card={c} size="lg" selected={c === open} dimmed={c === discard} onClick={() => tap(c)} title={c === discard ? '버릴 카드(다시 누르면 취소)' : c === open ? '보여 줄 카드' : undefined} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={!discard}
|
||||
onClick={() => {
|
||||
setDiscard(null);
|
||||
setOpen(null);
|
||||
}}
|
||||
>
|
||||
다시 고르기
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" disabled={!discard || !open || pending} onClick={() => act({ type: 'choose', discardId: discard, openId: open })}>
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 교환(바둑이 / 5카드 드로우): tap to lift the cards to change. */
|
||||
function DrawPicker({ v, hole, act, pending }: { v: PokerView; hole: string[]; act: (a: unknown) => void; pending: boolean }) {
|
||||
const [sel, setSel] = useState<string[]>([]);
|
||||
const { drawMax, drawAceBonus } = v.my;
|
||||
const keepsAce = hole.some((c) => isAce(c) && !sel.includes(c));
|
||||
const limit = drawAceBonus && keepsAce ? drawMax + 1 : drawMax;
|
||||
const tooMany = sel.length > limit;
|
||||
const round = v.seats.find((s) => s.id === v.me)?.drawCounts.length ?? 0;
|
||||
const label = v.mode === 'badugi' ? (['아침', '점심', '저녁'][round] ?? '교환') : '교환';
|
||||
return (
|
||||
<div className="stack" style={{ gap: 8 }}>
|
||||
<p style={{ margin: 0, textAlign: 'center' }}>
|
||||
<strong>{label}: 바꿀 카드를 누르세요</strong>
|
||||
<br />
|
||||
<span className="small muted">
|
||||
{drawAceBonus ? '3장까지 바꿀 수 있어요(A를 남기면 4장).' : `${drawMax}장까지 바꿀 수 있어요.`} 모두 동시에 내고, 버린 카드는 공개되지 않아요.
|
||||
</span>
|
||||
</p>
|
||||
<div className="pk-mine">
|
||||
{hole.map((c) => (
|
||||
<PlayingCard key={c} card={c} size="lg" selected={sel.includes(c)} onClick={() => setSel((s) => (s.includes(c) ? s.filter((x) => x !== c) : [...s, c]))} />
|
||||
))}
|
||||
</div>
|
||||
{tooMany && <p className="field-error" style={{ margin: 0, textAlign: 'center' }}>{limit}장까지만 바꿀 수 있어요.</p>}
|
||||
<button type="button" className="btn btn-primary btn-block" disabled={pending || tooMany} onClick={() => act({ type: 'draw', cardIds: sel })}>
|
||||
{sel.length === 0 ? '패스(안 바꾸기)' : `${sel.length}장 바꾸기`}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeclarePicker({ v, act, pending }: { v: PokerView; act: (a: unknown) => void; pending: boolean }) {
|
||||
const sub: Record<Side, string> = {
|
||||
high: v.my.handName ?? '',
|
||||
low: v.my.lowName ?? '로우 없음',
|
||||
swing: '하이·로우 둘 다 이겨야 해요',
|
||||
};
|
||||
return (
|
||||
<div className="stack" style={{ gap: 8 }}>
|
||||
<p style={{ margin: 0, textAlign: 'center' }}>
|
||||
<strong>하이·로우·스윙 중 하나를 선언하세요.</strong> <span className="small muted">모두 고른 뒤 한꺼번에 공개돼요.</span>
|
||||
</p>
|
||||
<div className="pk-actions" style={{ gridTemplateColumns: 'repeat(3, 1fr)' }}>
|
||||
{(['high', 'low', 'swing'] as Side[]).map((side) => (
|
||||
<button key={side} type="button" className="btn btn-primary" disabled={pending} onClick={() => act({ type: 'declare', side })}>
|
||||
<span>{SIDE_KO[side]}</span>
|
||||
<small>{sub[side]}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BetBar({ v, me, players, act, pending }: AreaProps & { me: string }) {
|
||||
const myTurn = v.toAct === me;
|
||||
const legal = new Map<BetActionType, LegalBetView>(v.my.legal.map((l) => [l.type, l]));
|
||||
const [hover, setHover] = useState<BetActionType | null>(null);
|
||||
const [confirmFold, setConfirmFold] = useState<BetActionType | null>(null);
|
||||
const [raising, setRaising] = useState(false);
|
||||
const seat = v.seats.find((s) => s.id === me)!;
|
||||
useEffect(() => setRaising(false), [v.handNo, v.street, myTurn]);
|
||||
|
||||
const press = (t: BetActionType) => {
|
||||
if ((t === 'k.die' || t === 'nl.fold') && v.my.toCall === 0) return setConfirmFold(t);
|
||||
if (t === 'nl.raise') return setRaising(true);
|
||||
act({ type: t });
|
||||
};
|
||||
|
||||
const korean = v.betStyle === 'korean';
|
||||
const types: BetActionType[] = korean
|
||||
? KOREAN_ORDER.filter((t) => t !== 'k.full' || v.options.koreanMaxRaise === 'full')
|
||||
: ['nl.fold', legal.has('nl.call') ? 'nl.call' : 'nl.check', 'nl.raise', 'nl.allIn'];
|
||||
const hoverBet = hover ? legal.get(hover) : undefined;
|
||||
const raise = legal.get('nl.raise');
|
||||
|
||||
return (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
{raising && raise ? (
|
||||
<RaisePanel v={v} seat={seat} raise={raise} act={act} pending={pending} onCancel={() => setRaising(false)} />
|
||||
) : (
|
||||
<div className="pk-actions" role="group" aria-label="베팅">
|
||||
{types.map((t) => {
|
||||
const l = legal.get(t);
|
||||
const enabled = myTurn && !!l && !pending;
|
||||
const cls = t === 'k.die' || t === 'nl.fold' ? 'btn-danger' : t.endsWith('check') || t.endsWith('call') ? 'btn-primary' : 'btn-soft';
|
||||
const amount = t === 'nl.raise' && l ? `${formatChips(l.minTo!)}부터` : l && l.amount > 0 ? `+${formatChips(l.amount)}${l.allIn ? ' 올인' : ''}` : '';
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
className={`btn ${cls}`}
|
||||
disabled={!enabled}
|
||||
title={BET_HELP[t]}
|
||||
onMouseEnter={() => setHover(t)}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
onFocus={() => setHover(t)}
|
||||
onBlur={() => setHover(null)}
|
||||
onClick={() => press(t)}
|
||||
>
|
||||
<span>{BET_KO[t]}</span>
|
||||
<small>{amount || (t === 'k.die' || t === 'nl.fold' ? '포기' : l ? '0 칩' : '-')}</small>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<p className="small muted" style={{ margin: 0, minHeight: '1.4em', textAlign: 'center' }}>
|
||||
{myTurn
|
||||
? hoverBet && BET_HELP[hover!]
|
||||
? `${BET_KO[hover!]}: ${BET_HELP[hover!]}. 내고 나면 내 칩 ${formatChips(seat.stack - hoverBet.amount)}, 모인 칩 ${formatChips(v.pot + hoverBet.amount)}`
|
||||
: v.my.toCall > 0
|
||||
? `내 차례예요. 맞추려면 ${formatChips(v.my.toCall)}이 필요해요.`
|
||||
: '내 차례예요. 버튼에 적힌 칩이 바로 나가요.'
|
||||
: v.toAct
|
||||
? `${nick(players, v.toAct)}님 차례예요.`
|
||||
: ''}
|
||||
</p>
|
||||
{confirmFold && (
|
||||
<ConfirmSheet
|
||||
title="체크할 수 있는데 포기할까요?"
|
||||
body="지금은 칩을 더 내지 않고 넘어갈 수 있어요."
|
||||
confirm="그래도 포기"
|
||||
danger
|
||||
onConfirm={() => {
|
||||
act({ type: confirmFold });
|
||||
setConfirmFold(null);
|
||||
}}
|
||||
onClose={() => setConfirmFold(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** NL/PL raise: slider + quick buttons (최소, 1/2팟, 팟, 최대) + ∓ 기본 단위, then 확인. Amounts are "총 몇 칩까지". */
|
||||
function RaisePanel({ v, seat, raise, act, pending, onCancel }: { v: PokerView; seat: SeatView; raise: LegalBetView; act: (a: unknown) => void; pending: boolean; onCancel: () => void }) {
|
||||
const min = raise.minTo!;
|
||||
const max = raise.maxTo!;
|
||||
const [to, setTo] = useState(min);
|
||||
const clamp = (x: number) => Math.max(min, Math.min(max, Math.round(x)));
|
||||
const potAfterCall = v.pot + v.my.toCall;
|
||||
const quick: [string, number][] = [
|
||||
['최소', min],
|
||||
['1/2 팟', v.currentBet + Math.floor(potAfterCall / 2)],
|
||||
['팟', v.currentBet + potAfterCall],
|
||||
[v.betStyle === 'potLimit' ? '최대' : '올인', max],
|
||||
];
|
||||
const add = to - seat.roundBet;
|
||||
const allIn = to >= seat.roundBet + seat.stack;
|
||||
return (
|
||||
<div className="card pk-raise" style={{ padding: 12 }}>
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<strong>
|
||||
{v.currentBet === 0 ? '벳' : '레이즈'}: 이번 라운드 총 {formatChips(to)}
|
||||
</strong>
|
||||
<span className="small muted">
|
||||
지금 낼 칩 +{formatChips(add)}
|
||||
{allIn ? ' (올인)' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="row" style={{ gap: 6, flexWrap: 'nowrap' }}>
|
||||
<button type="button" className="btn btn-secondary" aria-label="기본 단위만큼 줄이기" onClick={() => setTo(clamp(to - v.baseUnit))}>
|
||||
−
|
||||
</button>
|
||||
<input type="range" min={min} max={max} step={1} value={to} onChange={(e) => setTo(clamp(Number(e.target.value)))} aria-label="올릴 칩" style={{ flex: 1 }} />
|
||||
<button type="button" className="btn btn-secondary" aria-label="기본 단위만큼 늘리기" onClick={() => setTo(clamp(to + v.baseUnit))}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
{quick.map(([label, x]) => (
|
||||
<button key={label} type="button" className="btn btn-soft" style={{ flex: 1 }} onClick={() => setTo(clamp(x))}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<button type="button" className="btn btn-secondary" style={{ flex: 1 }} onClick={onCancel}>
|
||||
취소
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" style={{ flex: 2 }} disabled={pending} onClick={() => act(allIn && v.betStyle === 'noLimit' ? { type: 'nl.allIn' } : { type: 'nl.raise', to })}>
|
||||
확인: {formatChips(to)}까지
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- side
|
||||
|
||||
const RANKS_INTL = ['스트레이트 플러시(로열 포함)', '포카드', '풀하우스', '플러시', '스트레이트', '트리플', '투페어', '원페어', '하이카드'];
|
||||
const RANKS_KOREAN = ['로열 스트레이트 플러시', '백 스트레이트 플러시', '스트레이트 플러시', '포카드', '풀하우스', '플러시', '마운틴(A-K-Q-J-10)', '백 스트레이트(A-2-3-4-5)', '스트레이트', '트리플', '투페어', '원페어', '탑(노페어)'];
|
||||
|
||||
function HandRanks({ mode, o }: { mode: PokerMode; o: Pick<PokerOptions, 'indianCards' | 'indianDeck' | 'hiLoLow' | 'hiLoQualifier'> }) {
|
||||
if (mode === 'badugi') {
|
||||
return (
|
||||
<div className="small stack" style={{ gap: 4 }}>
|
||||
<span>낮을수록 좋은 로우 게임이에요. A가 가장 낮아요.</span>
|
||||
<span>
|
||||
4장 중 <strong>숫자도 무늬도 겹치지 않는 카드</strong>가 많을수록 셉니다: 메이드(4장) > 베이스(3장) > 투베이스(2장) > 노베이스.
|
||||
</span>
|
||||
<span>같은 등급이면 가장 높은 카드부터 비교해 낮은 쪽이 이겨요. 골프(A-2-3-4)가 최강, 세컨드(A-2-3-5), 써드(A-2-4-5).</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (mode === 'indian') {
|
||||
return (
|
||||
<div className="small stack" style={{ gap: 4 }}>
|
||||
<span>내 이마 카드는 나만 못 보고, 다른 사람 카드는 다 보여요.</span>
|
||||
<span>{o.indianDeck === 'd20' ? '10이 가장 높고 A(1)가 가장 낮아요.' : 'A가 가장 높고 2가 가장 낮아요.'} 무늬는 보지 않아요.</span>
|
||||
{o.indianCards === 2 && <span>2장일 때: 페어 > 노페어. 노페어끼리는 높은 카드 → 낮은 카드 순으로 비교해요.</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const korean = isSevenMode(mode);
|
||||
return (
|
||||
<div className="stack" style={{ gap: 4 }}>
|
||||
<ol className="small" style={{ margin: 0, paddingLeft: 20 }}>
|
||||
{(korean ? RANKS_KOREAN : RANKS_INTL).map((r) => (
|
||||
<li key={r}>{r}</li>
|
||||
))}
|
||||
</ol>
|
||||
{korean && <span className="small muted">같은 족보면 숫자, 그다음 무늬(♠ > ◆ > ♥ > ♣)로 정해요.</span>}
|
||||
{mode === 'sevenHiLo' && (
|
||||
<span className="small muted">
|
||||
로우: 서로 다른 숫자 5장, 낮을수록 좋음({o.hiLoLow === 'korean' ? '스트레이트·플러시 불인정, 최고 A-2-3-4-6' : '최고 A-2-3-4-5'}
|
||||
{o.hiLoQualifier === 'eight' ? ', 8 이하만 인정' : ''}).
|
||||
</span>
|
||||
)}
|
||||
{!korean && <span className="small muted">5장이 같은 서열이면 나눠 가져요(무늬는 보지 않아요).</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PokerSide({ view: v, me, players, act, pending, deadline }: BoardProps<PokerView>) {
|
||||
const [confirmQuit, setConfirmQuit] = useState(false);
|
||||
const ranked = [...v.seats].sort((a, b) => b.stack - a.stack);
|
||||
const scoreBy = v.options.endCondition === 'hands' || v.handsPlayed >= 300;
|
||||
return (
|
||||
<>
|
||||
<style>{CSS}</style>
|
||||
<PlayChipNotice />
|
||||
<div className="card stack" style={{ gap: 6, padding: 12 }}>
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<strong>
|
||||
{MODE_KO[v.mode]} · {v.phase === 'finished' ? '게임 끝' : `${Math.max(1, v.handNo)}판${v.options.endCondition === 'hands' ? ` / ${v.options.handLimit}판` : ''}`}
|
||||
</strong>
|
||||
{v.phase !== 'finished' && v.waiting.length > 0 && <TurnTimer deadline={deadline} totalMs={v.deadlineTotalMs} mine={isMyTurn(v, me)} size={36} />}
|
||||
</div>
|
||||
<span className="small muted">
|
||||
{v.betStyle === 'korean' ? `한국식 베팅(${v.options.koreanMaxRaise === 'full' ? '풀까지' : '하프까지'})` : v.betStyle === 'potLimit' ? '팟리밋' : '노리밋'} · 기본 {formatChips(v.baseUnit)}
|
||||
</span>
|
||||
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }}>
|
||||
{ranked.map((s) => (
|
||||
<li key={s.id} className="row" style={{ gap: 6, flexWrap: 'nowrap', opacity: s.status === 'eliminated' ? 0.55 : 1 }}>
|
||||
<Avatar user={userOf(players, s.id)} size={24} />
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: s.id === me ? 800 : 600 }}>
|
||||
{nick(players, s.id)}
|
||||
{s.rebuys > 0 ? ` · 리바이 ${s.rebuys}` : ''}
|
||||
{s.status === 'eliminated' ? ' · 탈락' : s.status === 'left' ? ' · 나감' : s.away ? ' · 자리 비움' : ''}
|
||||
</span>
|
||||
<Chips n={s.stack} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
{v.result && (
|
||||
<div className="card stack" style={{ gap: 4, padding: 12 }}>
|
||||
<strong>최종 순위</strong>
|
||||
{v.result.ranking.map((g, i) => {
|
||||
const sc = v.result!.scores[g[0]!] ?? 0;
|
||||
return (
|
||||
<span key={i}>
|
||||
{v.result!.ranking.slice(0, i).reduce((a, x) => a + x.length, 1)}위 {g.map((id) => nick(players, id)).join(', ')}
|
||||
{scoreBy ? ` (${sc < 0 ? '-' : ''}${Math.abs(sc).toLocaleString('ko-KR')} 칩)` : ''}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{scoreBy && <span className="small muted">점수 = 마지막 칩 − 시작 칩 × 리바이 횟수</span>}
|
||||
{v.stats.bestHand && (
|
||||
<span className="small">
|
||||
최고 족보: {nick(players, v.stats.bestHand.playerId)} · {v.stats.bestHand.handName} ({v.stats.bestHand.handNo}판)
|
||||
</span>
|
||||
)}
|
||||
{v.stats.biggestPot && <span className="small">가장 큰 판: {formatChips(v.stats.biggestPot.amount)} ({v.stats.biggestPot.handNo}판)</span>}
|
||||
</div>
|
||||
)}
|
||||
<details className="card wide-open" open style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>족보</summary>
|
||||
<HandRanks mode={v.mode} o={v.options} />
|
||||
</details>
|
||||
{v.mode === 'indian' && v.indianUsed.length > 0 && (
|
||||
<details className="card" style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>이미 나온 카드 ({v.indianUsed.length}장)</summary>
|
||||
<div className="row" style={{ gap: 2, marginTop: 6 }}>
|
||||
{v.indianUsed.map((c, i) => (
|
||||
<PlayingCard key={`${c}${i}`} card={c} size="sm" />
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
{v.log.length > 0 && (
|
||||
<details className="card" style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기록</summary>
|
||||
<ol style={{ listStyle: 'none', margin: '8px 0 0', padding: 0, display: 'grid', gap: 2, maxHeight: 200, overflow: 'auto' }}>
|
||||
{[...v.log].reverse().map((e, i) => (
|
||||
<li key={i} className="small">
|
||||
<span className="muted">{e.hand}판</span> {e.by ? `${nick(players, e.by)}: ` : ''}
|
||||
{e.msg}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
)}
|
||||
{v.my.canRebuy && !confirmQuit && (
|
||||
<Sheet onClose={() => {}} label="칩 다시 받기">
|
||||
<h2>칩을 모두 잃었어요</h2>
|
||||
<p className="muted">
|
||||
놀이용 칩 {formatChips(v.options.startingChips)}을 다시 받고 계속할 수 있어요{v.my.rebuysLeft !== null ? ` (남은 횟수 ${v.my.rebuysLeft}번)` : ''}. 점수에서는 다시 받은 만큼 빠져요.
|
||||
</p>
|
||||
<div className="row">
|
||||
<button className="btn btn-primary" style={{ flex: 1 }} disabled={pending} onClick={() => act({ type: 'rebuy', accept: true })}>
|
||||
칩 다시 받기
|
||||
</button>
|
||||
<button className="btn btn-danger" style={{ flex: 1 }} disabled={pending} onClick={() => setConfirmQuit(true)}>
|
||||
그만하기
|
||||
</button>
|
||||
</div>
|
||||
</Sheet>
|
||||
)}
|
||||
{confirmQuit && v.my.canRebuy && (
|
||||
<ConfirmSheet
|
||||
title="여기서 그만할까요?"
|
||||
body="그만하면 이번 게임에서 탈락하고 구경만 할 수 있어요."
|
||||
confirm="그만하기"
|
||||
danger
|
||||
onConfirm={() => act({ type: 'rebuy', accept: false })}
|
||||
onClose={() => setConfirmQuit(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- status
|
||||
|
||||
function isMyTurn(v: PokerView, me: string | null): boolean {
|
||||
if (!me || v.phase === 'finished' || v.handPhase === 'handEnd') return false;
|
||||
return v.waiting.includes(me);
|
||||
}
|
||||
|
||||
function statusText(v: PokerView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
|
||||
if (v.phase === 'rebuy') return v.my.canRebuy ? '칩을 다시 받을지 골라 주세요' : '칩을 다시 받을지 기다리는 중이에요';
|
||||
const mine = isMyTurn(v, me);
|
||||
switch (v.handPhase) {
|
||||
case 'betting':
|
||||
if (v.toAct === me) return v.my.handName && v.options.hints ? `내 차례예요 · 지금: ${v.my.handName}` : '내 차례예요. 베팅해 주세요';
|
||||
return v.toAct ? `${nick(players, v.toAct)}님 차례예요` : '카드를 나눠 주는 중이에요';
|
||||
case 'choice':
|
||||
return mine ? '내 차례예요. 버릴 카드와 보여 줄 카드를 골라 주세요' : '다른 사람이 카드를 고르는 중이에요';
|
||||
case 'draw':
|
||||
return mine ? '내 차례예요. 바꿀 카드를 골라 주세요' : '다른 사람이 바꿀 카드를 고르는 중이에요';
|
||||
case 'declare':
|
||||
return mine ? '내 차례예요. 하이·로우·스윙을 선언해 주세요' : '다른 사람이 선언하는 중이에요';
|
||||
case 'handEnd':
|
||||
return resultLine(v, players) || '판이 끝났어요';
|
||||
default:
|
||||
return '잠시만 기다려 주세요';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 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<PokerOptions>) {
|
||||
const [more, setMore] = useState(false);
|
||||
const set = (patch: Partial<PokerOptions>) => onChange({ ...o, ...patch });
|
||||
const tooBig = o.baseUnit * 20 > o.startingChips;
|
||||
const max = o.mode === 'holdem' || o.mode === 'omaha' || (o.mode === 'indian' && o.indianCards === 1 && o.indianDeck === 'd52') ? 10 : 6;
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<Field label="게임 모드">
|
||||
<Seg value={o.mode} options={(Object.keys(MODE_KO) as PokerMode[]).map((m) => [m, MODE_KO[m]])} onChange={(mode) => set({ mode })} />
|
||||
<span className="small muted">이 모드는 2~{max}명이 할 수 있어요.</span>
|
||||
</Field>
|
||||
<Field label="베팅 방식">
|
||||
<Seg
|
||||
value={o.betting}
|
||||
options={[
|
||||
['auto', '모드 기본'],
|
||||
['korean', '한국식(삥·따당·하프)'],
|
||||
['noLimit', '노리밋'],
|
||||
['potLimit', '팟리밋'],
|
||||
]}
|
||||
onChange={(betting) => set({ betting })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="시작 칩">
|
||||
<Seg value={o.startingChips} options={[[500, '500'], [1000, '1,000'], [2000, '2,000'], [5000, '5,000']]} onChange={(startingChips) => set({ startingChips })} />
|
||||
</Field>
|
||||
<Field label={o.mode === 'holdem' || o.mode === 'omaha' ? '빅 블라인드' : '기본 칩(앤티)'}>
|
||||
<Seg value={o.baseUnit} options={[[2, '2칩'], [10, '10칩'], [20, '20칩'], [50, '50칩']]} onChange={(baseUnit) => set({ baseUnit })} />
|
||||
{tooBig && <span className="field-error">기본 칩은 시작 칩의 20분의 1 이하로 골라 주세요.</span>}
|
||||
</Field>
|
||||
<Field label="끝내는 방식">
|
||||
<Seg value={o.endCondition} options={[['hands', '정해진 판 수'], ['lastStanding', '최후의 1인']]} onChange={(endCondition) => set({ endCondition })} />
|
||||
{o.endCondition === 'hands' && <Seg value={o.handLimit} options={[[10, '10판'], [20, '20판'], [30, '30판'], [50, '50판']]} onChange={(handLimit) => set({ handLimit })} />}
|
||||
</Field>
|
||||
<Field label="행동 제한 시간">
|
||||
<Seg value={o.turnSeconds} options={[[15, '15초'], [20, '20초'], [30, '30초'], [60, '60초']]} onChange={(turnSeconds) => set({ turnSeconds })} />
|
||||
</Field>
|
||||
{o.mode === 'indian' && (
|
||||
<Field label="인디언 포커">
|
||||
<Seg value={o.indianCards} options={[[1, '1장(이마)'], [2, '2장(이마 + 손)']]} onChange={(indianCards) => set({ indianCards })} />
|
||||
<Seg value={o.indianDeck} options={[['d20', '20장(1~10 두 벌)'], ['d52', '52장']]} onChange={(indianDeck) => set({ indianDeck })} />
|
||||
</Field>
|
||||
)}
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
|
||||
</button>
|
||||
{more && (
|
||||
<>
|
||||
<Field label="한국식 최대 레이즈">
|
||||
<Seg value={o.koreanMaxRaise} options={[['half', '하프까지'], ['full', '풀까지']]} onChange={(koreanMaxRaise) => set({ koreanMaxRaise })} />
|
||||
</Field>
|
||||
<Field label="칩 다시 받기(리바이)">
|
||||
<Seg value={o.rebuy} options={[['off', '안 함(탈락)'], ['limited', '횟수 제한'], ['unlimited', '무제한']]} onChange={(rebuy) => set({ rebuy })} />
|
||||
{o.rebuy === 'limited' && <Seg value={o.rebuyLimit} options={[[1, '1번'], [2, '2번'], [3, '3번']]} onChange={(rebuyLimit) => set({ rebuyLimit })} />}
|
||||
</Field>
|
||||
<Field label="기본 칩 2배로 올리기">
|
||||
<Seg value={o.blindUpEvery} options={[[0, '안 올림'], [5, '5판마다'], [10, '10판마다'], [15, '15판마다']]} onChange={(blindUpEvery) => set({ blindUpEvery })} />
|
||||
</Field>
|
||||
<Field label="다음 판 딜러">
|
||||
<Seg value={o.dealerRule} options={[['rotate', '시계 방향으로'], ['winner', '직전 판 승자']]} onChange={(dealerRule) => set({ dealerRule })} />
|
||||
</Field>
|
||||
<Field label="도움 표시">
|
||||
<Seg value={o.hints ? 'on' : 'off'} options={[['on', '지금 족보 보여 주기'], ['off', '보여 주지 않기']]} onChange={(k) => set({ hints: k === 'on' })} />
|
||||
</Field>
|
||||
{o.mode === 'sevenHiLo' && (
|
||||
<Field label="하이로우 로우 판정">
|
||||
<Seg value={o.hiLoLow} options={[['korean', '스트레이트·플러시 불인정'], ['aceToFive', 'A-2-3-4-5 최고']]} onChange={(hiLoLow) => set({ hiLoLow })} />
|
||||
<Seg value={o.hiLoQualifier} options={[['none', '제한 없음'], ['eight', '8 이하만 로우']]} onChange={(hiLoQualifier) => set({ hiLoQualifier })} />
|
||||
</Field>
|
||||
)}
|
||||
{o.mode === 'fiveDraw' && (
|
||||
<Field label="교환 장수">
|
||||
<Seg value={o.drawLimit} options={[['any', '0~5장'], ['three', '3장까지(A를 남기면 4장)']]} onChange={(drawLimit) => set({ drawLimit })} />
|
||||
</Field>
|
||||
)}
|
||||
{o.mode === 'indian' && (
|
||||
<>
|
||||
<Field label="가장 높은 카드를 단 채 다이하면 벌칙">
|
||||
<Seg value={o.indianFoldPenalty ? 'on' : 'off'} options={[['on', '벌칙 있음'], ['off', '없음']]} onChange={(k) => set({ indianFoldPenalty: k === 'on' })} />
|
||||
{o.indianFoldPenalty && <Seg value={o.indianPenaltyUnits} options={[[5, '기본 칩 5배'], [10, '10배'], [20, '20배']]} onChange={(indianPenaltyUnits) => set({ indianPenaltyUnits })} />}
|
||||
</Field>
|
||||
<Field label="동점일 때">
|
||||
<Seg value={o.indianTie} options={[['carry', '다음 판으로 넘김'], ['split', '나눠 가짐']]} onChange={(indianTie) => set({ indianTie })} />
|
||||
</Field>
|
||||
<Field label="덱 섞기">
|
||||
<Seg value={o.indianReshuffle} options={[['whenEmpty', '덱을 다 쓰면(카드 기억)'], ['everyHand', '판마다']]} onChange={(indianReshuffle) => set({ indianReshuffle })} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<Field label="판 결과 보여 주는 시간">
|
||||
<Seg value={o.handEndSeconds} options={[[3, '3초'], [4, '4초'], [6, '6초'], [10, '10초']]} onChange={(handEndSeconds) => set({ handEndSeconds })} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function optionsSummary(o: PokerOptions): string {
|
||||
const bet = o.betting === 'auto' ? '' : o.betting === 'korean' ? '한국식' : o.betting === 'noLimit' ? '노리밋' : '팟리밋';
|
||||
return [MODE_KO[o.mode], bet, `시작 ${o.startingChips.toLocaleString('ko-KR')}칩`, `기본 ${o.baseUnit}칩`, o.endCondition === 'hands' ? `${o.handLimit}판` : '최후의 1인', `${o.turnSeconds}초`]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
const MODE_RULES: Record<PokerMode, string> = {
|
||||
holdem: '각자 2장을 받고, 가운데 공용 카드 5장(플랍 3 → 턴 1 → 리버 1)과 합쳐 가장 좋은 5장을 만들어요. 베팅은 프리플랍·플랍·턴·리버 네 번이에요.',
|
||||
omaha: '각자 4장을 받는데, 반드시 내 카드 2장 + 공용 카드 3장으로만 5장을 만들어요. 나머지는 홀덤과 같아요.',
|
||||
sevenPoker: '4장을 받아 1장은 버리고 1장은 모두에게 보여 줘요(초이스). 그다음 공개 카드 3장과 마지막 히든 카드 1장을 받으며 네 번 베팅해요. 7장 중 가장 좋은 5장으로 겨뤄요. 공개 카드가 가장 좋은 사람(보스)이 먼저 베팅해요.',
|
||||
sevenHiLo: '7포커와 같이 진행한 뒤, 마지막에 하이·로우·스윙 중 하나를 동시에 선언해요. 하이 1등과 로우 1등이 칩을 반씩 나눠 가져요. 스윙은 두 쪽 다 이겨야 모두 가져가고, 하나라도 지면 아무것도 못 받아요.',
|
||||
badugi: '4장을 받고 아침·점심·저녁 세 번 카드를 바꿀 수 있어요(교환 사이마다 베팅). 숫자와 무늬가 겹치지 않으면서 낮은 카드일수록 좋아요.',
|
||||
fiveDraw: '5장을 받고 한 번 베팅한 뒤, 원하는 만큼 카드를 바꾸고 다시 베팅해요. 가장 좋은 5장 족보가 이겨요.',
|
||||
indian: '카드를 이마에 붙이듯 나만 못 보고 다른 사람은 모두 봐요. 상대 카드와 베팅만 보고 내 카드를 짐작해야 해요.',
|
||||
};
|
||||
|
||||
function Rules({ options }: { options?: PokerOptions }) {
|
||||
const o = options;
|
||||
const mode = o?.mode ?? 'sevenPoker';
|
||||
const style = o ? (o.betting !== 'auto' ? o.betting : mode === 'holdem' || mode === 'indian' ? 'noLimit' : mode === 'omaha' ? 'potLimit' : 'korean') : 'korean';
|
||||
return (
|
||||
<div className="stack">
|
||||
<p>
|
||||
<strong>{MODE_KO[mode]}</strong>: {MODE_RULES[mode]} 칩은 이 방에서만 쓰는 놀이용 점수예요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>베팅</strong>:{' '}
|
||||
{style === 'korean'
|
||||
? '체크(안 걸고 넘김), 삥(기본 칩만큼), 콜(앞사람만큼), 따당(앞사람이 건 만큼의 두 배), 쿼터·하프·풀(모인 칩의 4분의 1·절반·전부만큼 더), 다이(포기). 한 라운드에 올리기는 한 번뿐이고, 체크나 콜을 한 뒤에는 같은 라운드에서 올릴 수 없어요.'
|
||||
: `폴드(포기), 체크, 콜, 레이즈, 올인. 레이즈는 직전 레이즈만큼 이상 올려야 해요.${style === 'potLimit' ? ' 팟리밋은 모인 칩만큼까지만 올릴 수 있어요.' : ''}`}{' '}
|
||||
칩이 모자라면 가진 만큼 내고 올인하며, 그 이상 걸린 칩은 따로(사이드 팟) 겨뤄요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>족보</strong>
|
||||
</p>
|
||||
<HandRanks mode={mode} o={o ?? { indianCards: 1, indianDeck: 'd20', hiLoLow: 'korean', hiLoQualifier: 'none' }} />
|
||||
{mode === 'indian' && o?.indianFoldPenalty !== false && <p>가장 높은 카드를 이마에 단 채 다이하면 벌칙 칩을 내요. 벌칙 칩은 그 판 승자가 가져가요.</p>}
|
||||
<p>
|
||||
<strong>칩이 떨어지면</strong>:{' '}
|
||||
{o?.rebuy === 'off' ? '탈락하고 구경해요.' : '칩을 다시 받을지 고를 수 있어요. 다시 받은 만큼 최종 점수에서 빠져요.'}{' '}
|
||||
{o?.endCondition === 'lastStanding' ? '칩을 가진 사람이 한 명 남으면 끝나요.' : '정해진 판 수가 끝나면 (마지막 칩 − 시작 칩 × 다시 받은 횟수)로 순위를 정해요.'}
|
||||
</p>
|
||||
<p>시간 안에 고르지 않으면 걸린 칩이 없을 때는 체크, 있을 때는 포기로 처리돼요. 두 번 연속 시간을 넘기면 '자리 비움'이 되고 제한 시간이 짧아져요.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ui: GameUI<PokerView, PokerOptions> = {
|
||||
Board: PokerBoard,
|
||||
Side: PokerSide,
|
||||
statusText,
|
||||
isMyTurn,
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
@@ -7,6 +7,8 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
|
||||
chess: () => import('./chess'),
|
||||
janggi: () => import('./janggi'),
|
||||
baduk: () => import('./baduk'),
|
||||
seotda: () => import('./seotda'),
|
||||
poker: () => import('./poker'),
|
||||
};
|
||||
|
||||
const cache = new Map<string, GameUI>();
|
||||
|
||||
831
apps/web/src/games/seotda/index.tsx
Normal file
831
apps/web/src/games/seotda/index.tsx
Normal file
@@ -0,0 +1,831 @@
|
||||
/** 섯다 화면 (docs/games/seotda.md §9). Chips are room-only play points. */
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type {
|
||||
BetType,
|
||||
CardSlot,
|
||||
PotOutcome,
|
||||
SeotdaCardView,
|
||||
SeotdaOptions,
|
||||
SeotdaSeatView,
|
||||
SeotdaView,
|
||||
SpecialKind,
|
||||
} from '@bg/games/seotda';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { Avatar, ConfirmSheet, Sheet } from '../../components/ui';
|
||||
import { TurnTimer } from '../../components/TurnTimer';
|
||||
import { HwatuCard, type HwatuInfo } from '../../components/cards/HwatuCard';
|
||||
import { Chips, PlayChipNotice, formatChips } from '../../components/cards/Chips';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
|
||||
const BET_ORDER: BetType[] = ['die', 'check', 'ping', 'call', 'ddadang', 'quarter', 'half', 'full'];
|
||||
const BET_KO: Record<BetType, string> = { check: '체크', ping: '삥', call: '콜', ddadang: '따당', quarter: '쿼터', half: '하프', full: '풀', die: '다이' };
|
||||
const BET_HELP: Record<BetType, string> = {
|
||||
check: '칩을 걸지 않고 넘겨요',
|
||||
ping: '기본 칩만큼 걸어요',
|
||||
call: '앞사람만큼 맞춰요',
|
||||
ddadang: '앞사람이 건 만큼의 두 배',
|
||||
quarter: '모인 칩의 4분의 1만큼 더',
|
||||
half: '모인 칩의 절반만큼 더',
|
||||
full: '모인 칩만큼 더',
|
||||
die: '이번 판을 포기해요',
|
||||
};
|
||||
const STRENGTH_COLOR = { high: 'var(--success)', mid: 'var(--warning)', low: 'var(--text-sub)' } as const;
|
||||
const STRENGTH_KO = { high: '센 패', mid: '중간 패', low: '약한 패' } as const;
|
||||
const REMATCH_KO = { gusa: '구사', mungGusa: '멍텅구리구사', tie: '동점' } as const;
|
||||
const REASON_KO: Record<PotOutcome['reason'], string> = {
|
||||
normal: '',
|
||||
ttaengjabi: '땡잡이',
|
||||
amhaeng: '암행어사',
|
||||
gusa: '구사 재경기',
|
||||
mungGusa: '멍텅구리구사 재경기',
|
||||
tie: '동점',
|
||||
limit: '재경기 횟수 초과로 나눔',
|
||||
fold: '모두 다이',
|
||||
};
|
||||
|
||||
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 isCard = (c: CardSlot): c is SeotdaCardView => !('hidden' in c);
|
||||
|
||||
/** Illustration card for the rules / 족보표 (no rule code needed on the client). */
|
||||
function illus(month: number, special: boolean): HwatuInfo {
|
||||
const gwang = [1, 3, 8].includes(month);
|
||||
const kind: HwatuInfo['kind'] = special ? (gwang ? 'gwang' : 'yeol') : month === 8 ? 'pi' : 'tti';
|
||||
return {
|
||||
id: (month - 1) * 4 + (special ? 0 : 1),
|
||||
month,
|
||||
kind,
|
||||
...(kind === 'tti' ? { tti: 'plain' as const } : {}),
|
||||
name: `${month}월 ${special ? (gwang ? '광' : '열끗') : month === 8 ? '기러기' : '띠'}`,
|
||||
};
|
||||
}
|
||||
|
||||
function totalMsOf(v: SeotdaView): number {
|
||||
const o = v.options;
|
||||
if (v.phase === 'rebuy' || v.handPhase === 'rejoin') return o.decisionSeconds * 1000;
|
||||
if (v.handPhase === 'choose') return (o.turnSeconds + 10) * 1000;
|
||||
if (v.handPhase === 'handEnd') return 4000;
|
||||
const turn = v.seats.find((s) => s.id === v.toAct);
|
||||
return turn?.away ? 5000 : o.turnSeconds * 1000;
|
||||
}
|
||||
|
||||
function specialFlash(v: SeotdaView): string | null {
|
||||
const pots = v.showdown?.pots ?? [];
|
||||
if (pots.some((p) => p.reason === 'ttaengjabi')) return '땡잡이!';
|
||||
if (pots.some((p) => p.reason === 'amhaeng')) return '암행어사 출두!';
|
||||
if (pots.some((p) => p.type === 'rematch' && p.reason === 'gusa')) return '구사 재경기';
|
||||
if (pots.some((p) => p.type === 'rematch' && p.reason === 'mungGusa')) return '멍텅구리구사 재경기';
|
||||
if (pots.some((p) => p.type === 'rematch')) return '동점 재경기';
|
||||
return null;
|
||||
}
|
||||
|
||||
const CSS = `
|
||||
.sd-table { position: relative; width: 100%; aspect-ratio: 16 / 11; border-radius: 180px; border: 10px solid var(--wood);
|
||||
background: radial-gradient(ellipse at center, rgba(255,255,255,0.12) 0%, rgba(255,255,255,0) 60%), var(--felt); box-shadow: var(--shadow); }
|
||||
.sd-seat { position: absolute; left: var(--x); top: var(--y); transform: translate(-50%, -50%); width: var(--seat-w, 168px); z-index: 1;
|
||||
background: var(--surface); color: var(--text); border-radius: 12px; padding: 6px 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.25);
|
||||
border: 3px solid transparent; display: flex; flex-direction: column; gap: 4px; font-size: 0.85rem; transition: border-color .2s; }
|
||||
.sd-seat.turn { border-color: var(--focus); }
|
||||
.sd-seat.me { border-color: var(--primary); }
|
||||
.sd-seat.me.turn { border-color: var(--focus); }
|
||||
.sd-seat.out { opacity: 0.6; }
|
||||
.sd-center { position: absolute; left: 50%; top: 47%; transform: translate(-50%, -50%); text-align: center; color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,.5); max-width: 46%; }
|
||||
.sd-flash { position: absolute; left: 50%; top: 30%; transform: translate(-50%, -50%); z-index: 3; font-size: 2.6rem; font-weight: 900; color: #fde047;
|
||||
text-shadow: 0 3px 8px rgba(0,0,0,.6); animation: sd-pop .35s ease-out; pointer-events: none; white-space: nowrap; }
|
||||
@keyframes sd-pop { from { transform: translate(-50%, -50%) scale(.5); opacity: 0 } to { transform: translate(-50%, -50%) scale(1); opacity: 1 } }
|
||||
.sd-actions { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
|
||||
.sd-actions .btn { min-height: 64px; flex-direction: column; gap: 0; padding: 0 8px; line-height: 1.15; }
|
||||
.sd-actions .btn small { font-weight: 600; font-size: .85rem; opacity: .9; }
|
||||
.sd-mine { display: flex; gap: 14px; align-items: flex-end; flex-wrap: wrap; justify-content: center; }
|
||||
.sd-badge { display: inline-block; border-radius: 999px; padding: 0 8px; font-weight: 800; font-size: .78rem; }
|
||||
@media (max-width: 719px) {
|
||||
.sd-table { aspect-ratio: auto; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 10px; border-radius: 20px; border-width: 6px; }
|
||||
.sd-seat { position: static; transform: none; width: auto; }
|
||||
.sd-center { position: static; transform: none; grid-column: 1 / -1; order: -1; max-width: none; padding: 8px 0; }
|
||||
.sd-flash { position: fixed; top: 40%; }
|
||||
.sd-actions { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------- board
|
||||
|
||||
function SeatBox({ seat, view: v, players, me, deadline, x, y }: { seat: SeotdaSeatView; view: SeotdaView; players: Record<string, PublicUser>; me: string | null; deadline: number | null; x: number; y: number }) {
|
||||
const u = userOf(players, seat.id);
|
||||
const out = seat.status === 'eliminated' || seat.folded || (!seat.inHand && v.handPhase !== null);
|
||||
const badges: [string, string][] = [];
|
||||
if (seat.left) badges.push(['나감', 'var(--danger)']);
|
||||
else if (seat.away) badges.push(['자리 비움', 'var(--warning)']);
|
||||
if (seat.status === 'eliminated') badges.push(['탈락', 'var(--text-sub)']);
|
||||
if (seat.status === 'busted') badges.push(['리바이 대기', 'var(--warning)']);
|
||||
if (seat.folded) badges.push(['다이', 'var(--danger)']);
|
||||
if (seat.allIn) badges.push(['올인', 'var(--primary)']);
|
||||
if (seat.chosen === true) badges.push(['고름', 'var(--success)']);
|
||||
if (seat.chosen === false) badges.push(['고르는 중', 'var(--text-sub)']);
|
||||
const timed = seat.isTurn || seat.chosen === false;
|
||||
return (
|
||||
<div
|
||||
className={`sd-seat${seat.isTurn ? ' turn' : ''}${seat.id === me ? ' me' : ''}${out ? ' out' : ''}`}
|
||||
style={{ '--x': `${x}%`, '--y': `${y}%` } as React.CSSProperties}
|
||||
aria-label={`${u.nickname} ${formatChips(seat.stack)}${seat.isDealer ? ', 선' : ''}${badges.length ? `, ${badges.map((b) => b[0]).join(', ')}` : ''}`}
|
||||
>
|
||||
<div className="row" style={{ gap: 5, flexWrap: 'nowrap' }}>
|
||||
<Avatar user={u} size={22} />
|
||||
<strong style={{ flex: 1, minWidth: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{u.nickname}
|
||||
{seat.id === me && <span className="muted"> (나)</span>}
|
||||
</strong>
|
||||
{seat.isDealer && (
|
||||
<span className="sd-badge" style={{ background: '#facc15', color: '#422006' }} title="선(먼저 하는 사람)">
|
||||
선
|
||||
</span>
|
||||
)}
|
||||
{timed && <TurnTimer deadline={deadline} totalMs={totalMsOf(v)} mine={seat.id === me} size={28} />}
|
||||
</div>
|
||||
<div className="row" style={{ gap: 6, flexWrap: 'nowrap', alignItems: 'flex-start' }}>
|
||||
<div className="stack" style={{ gap: 2, flex: 1, minWidth: 0 }}>
|
||||
<Chips n={seat.stack} strong />
|
||||
{((seat.lastAction && !seat.folded) || seat.roundBet > 0) && (
|
||||
<span className="small" style={{ whiteSpace: 'nowrap' }} title="마지막 행동 · 이번 라운드에 건 칩">
|
||||
{seat.lastAction && !seat.folded && <span className="muted">{BET_KO[seat.lastAction.type]} </span>}
|
||||
{seat.roundBet > 0 && `+${seat.roundBet.toLocaleString('ko-KR')}`}
|
||||
</span>
|
||||
)}
|
||||
<div className="row" style={{ gap: 3 }}>
|
||||
{badges.map(([t, c]) => (
|
||||
<span key={t} className="sd-badge" style={{ background: c, color: '#fff' }}>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{seat.cards.length > 0 && (
|
||||
<div style={{ display: 'flex', flex: 'none' }}>
|
||||
{seat.cards.map((c, i) => (
|
||||
<HwatuCard key={isCard(c) ? c.key : `h${i}`} card={isCard(c) ? c : null} size="sm" style={i > 0 ? { marginLeft: -14 } : undefined} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{seat.handName && <strong style={{ color: 'var(--primary)', textAlign: 'center' }}>{seat.handName}</strong>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CenterInfo({ view: v, players }: { view: SeotdaView; players: Record<string, PublicUser> }) {
|
||||
const limit = v.options.endCondition === 'hands' ? `/${v.options.handLimit}` : '';
|
||||
// On the result screen the paid-out pots are no longer in v.pot (only a carried rematch pot is).
|
||||
const paid = v.handPhase === 'handEnd' ? (v.foldWin?.amount ?? (v.showdown?.pots ?? []).filter((p) => p.type !== 'rematch').reduce((a, p) => a + p.amount, 0)) : 0;
|
||||
return (
|
||||
<div className="sd-center">
|
||||
{v.rematchNo > 0 && v.rematchReason && (
|
||||
<div className="sd-badge" style={{ background: '#fde047', color: '#422006', fontSize: '1rem', padding: '2px 12px', marginBottom: 6 }}>
|
||||
재경기 {v.rematchNo}회차 · {REMATCH_KO[v.rematchReason]}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: '0.95rem', opacity: 0.9 }}>모인 칩</div>
|
||||
<div style={{ fontSize: '2.2rem', fontWeight: 900, fontVariantNumeric: 'tabular-nums' }} aria-live="polite">
|
||||
{formatChips(v.pot + paid)}
|
||||
</div>
|
||||
{v.pots.length > 1 && (
|
||||
<div className="small" style={{ opacity: 0.9 }}>
|
||||
{v.pots.map((p, i) => (
|
||||
<div key={i}>
|
||||
{i === v.pots.length - 1 ? '메인' : `사이드 ${i + 1}`}: {formatChips(p.amount)} ({p.eligible.map((id) => nick(players, id)).join('·')})
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="small" style={{ opacity: 0.85, marginTop: 4 }}>
|
||||
{v.phase === 'finished' ? '게임 끝' : `${Math.max(1, v.handNo)}${limit}판`} · 학교 {formatChips(v.baseUnit)}
|
||||
{v.currentBet > 0 && v.handPhase === 'betting' ? ` · 이번 라운드 ${formatChips(v.currentBet)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SeotdaBoard({ view: v, me, players, act, pending, deadline }: BoardProps<SeotdaView>) {
|
||||
const n = v.seats.length;
|
||||
const myIdx = Math.max(0, v.seats.findIndex((s) => s.id === me));
|
||||
const [flash, setFlash] = useState<string | null>(null);
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const prevToAct = useRef(v.toAct);
|
||||
const showKey = `${v.handNo}-${v.rematchNo}-${v.handPhase}`;
|
||||
|
||||
useEffect(() => {
|
||||
const f = v.handPhase === 'handEnd' ? specialFlash(v) : null;
|
||||
if (f) {
|
||||
setFlash(f);
|
||||
const t = setTimeout(() => setFlash(null), 1400);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
return undefined;
|
||||
}, [showKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (v.toAct && v.toAct !== prevToAct.current) {
|
||||
if (v.toAct === me) {
|
||||
haptic(40);
|
||||
sounds.myTurn();
|
||||
setAnnounce('내 차례예요.');
|
||||
} else setAnnounce(`${nick(players, v.toAct)}님 차례예요.`);
|
||||
}
|
||||
prevToAct.current = v.toAct;
|
||||
}, [v.toAct]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (v.handPhase !== 'handEnd') return;
|
||||
if (v.foldWin) setAnnounce(`${nick(players, v.foldWin.winner)}님이 ${formatChips(v.foldWin.amount)}을 가져갔어요.`);
|
||||
else if (v.showdown) setAnnounce(resultLine(v, players));
|
||||
const won = v.foldWin?.winner === me || v.showdown?.pots.some((p) => p.type !== 'rematch' && p.winners.includes(me ?? ''));
|
||||
if (me && won) sounds.win();
|
||||
}, [showKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div className="board-wrap stack" style={{ gap: 12 }}>
|
||||
<style>{CSS}</style>
|
||||
<div className="sd-table" role="region" aria-label="섯다 테이블" style={{ '--seat-w': v.options.cardMode === 'three' ? '196px' : n > 6 ? '150px' : '172px' } as React.CSSProperties}>
|
||||
{v.seats.map((s, i) => {
|
||||
const rel = (i - myIdx + n) % n;
|
||||
const ang = Math.PI / 2 + (rel * 2 * Math.PI) / n;
|
||||
return <SeatBox key={s.id} seat={s} view={v} players={players} me={me} deadline={deadline} x={50 + 40 * Math.cos(ang)} y={50 + 38 * Math.sin(ang)} />;
|
||||
})}
|
||||
<CenterInfo view={v} players={players} />
|
||||
{flash && <div className="sd-flash">{flash}</div>}
|
||||
</div>
|
||||
<MyArea view={v} me={me} players={players} act={act} pending={pending} />
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function resultLine(v: SeotdaView, players: Record<string, PublicUser>): string {
|
||||
if (v.foldWin) return `${nick(players, v.foldWin.winner)}님이 ${formatChips(v.foldWin.amount)}을 가져갔어요(모두 다이).`;
|
||||
if (!v.showdown) return '';
|
||||
return v.showdown.pots
|
||||
.map((p) => {
|
||||
const who = p.winners.map((id) => nick(players, id)).join('·');
|
||||
const name = v.showdown!.hands.find((h) => h.id === p.winners[0])?.name ?? '';
|
||||
if (p.type === 'rematch') return `${REASON_KO[p.reason]}: ${who}님이 다시 겨뤄요(${formatChips(p.amount)})`;
|
||||
if (p.type === 'split') return `${who}님이 ${formatChips(p.amount)}을 나눠 가졌어요${p.reason === 'limit' ? '(재경기 횟수 초과)' : ''}`;
|
||||
return `${who}님이 ${name}${p.reason === 'normal' ? '' : ' 발동'}(으)로 ${formatChips(p.amount)}을 가져갔어요`;
|
||||
})
|
||||
.join(' / ');
|
||||
}
|
||||
|
||||
/** My cards, hand name, 3장 choice and the action buttons. */
|
||||
function MyArea({ view: v, me, players, act, pending }: { view: SeotdaView; me: string | null; players: Record<string, PublicUser>; act: (a: unknown) => void; pending: boolean }) {
|
||||
const seat = v.seats.find((s) => s.id === me);
|
||||
const [pick, setPick] = useState<number[]>([]);
|
||||
const [hover, setHover] = useState<BetType | null>(null);
|
||||
useEffect(() => setPick([]), [v.handNo, v.rematchNo, v.handPhase]);
|
||||
if (!me || !seat) return v.handPhase === 'handEnd' ? <ResultPanel view={v} players={players} /> : null;
|
||||
const myCards = seat.cards.filter(isCard);
|
||||
const choosing = v.handPhase === 'choose' && seat.chosen === false;
|
||||
const preview = choosing && pick.length === 2 ? v.myCombos?.find((c) => c.cardIds.includes(pick[0]!) && c.cardIds.includes(pick[1]!)) : null;
|
||||
const myTurn = v.toAct === me && v.handPhase === 'betting';
|
||||
const legal = new Map(v.legal.map((l) => [l.type, l]));
|
||||
const hoverBet = hover ? legal.get(hover) : undefined;
|
||||
const toggle = (id: number) => setPick((p) => (p.includes(id) ? p.filter((x) => x !== id) : p.length >= 2 ? [p[1]!, id] : [...p, id]));
|
||||
const showHand = myCards.length > 0 && seat.inHand;
|
||||
return (
|
||||
<div className="stack" style={{ gap: 10 }}>
|
||||
{showHand && (
|
||||
<div className="card stack" style={{ gap: 10, padding: 12 }}>
|
||||
<div className="sd-mine">
|
||||
{myCards.map((c) => {
|
||||
const chosen = v.myChosen?.includes(c.id);
|
||||
return (
|
||||
<HwatuCard
|
||||
key={c.key}
|
||||
card={c}
|
||||
size="lg"
|
||||
selected={choosing && pick.includes(c.id)}
|
||||
highlight={!!chosen}
|
||||
dimmed={!!v.myChosen && !chosen}
|
||||
onClick={choosing ? () => toggle(c.id) : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{v.myHand && (
|
||||
<div className="stack" style={{ gap: 2, minWidth: 160, maxWidth: 320 }}>
|
||||
<span style={{ fontSize: '1.5rem', fontWeight: 900, color: v.myHand.strength ? STRENGTH_COLOR[v.myHand.strength] : undefined }}>
|
||||
지금: {v.myHand.name}
|
||||
{v.myHand.strength && <span className="small" style={{ marginLeft: 6 }}>({STRENGTH_KO[v.myHand.strength]})</span>}
|
||||
</span>
|
||||
<span className="small muted">{v.myHand.hint}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{seat.folded && <p className="small muted" style={{ margin: 0, textAlign: 'center' }}>이번 판은 다이했어요. 내 패는 나만 볼 수 있어요.</p>}
|
||||
{choosing && (
|
||||
<div className="stack" style={{ gap: 8 }}>
|
||||
<p style={{ margin: 0, textAlign: 'center' }}>
|
||||
<strong>쓸 카드 2장을 골라 주세요.</strong> 남는 1장은 아무에게도 보이지 않아요.
|
||||
{preview && (
|
||||
<>
|
||||
{' '}
|
||||
고른 조합: <strong>{preview.name}</strong>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<div className="row" style={{ justifyContent: 'center' }}>
|
||||
{v.options.hints && v.myCombos && (
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setPick([...v.myCombos!.find((c) => c.best)!.cardIds])}>
|
||||
추천 조합
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={pick.length !== 2 || pending}
|
||||
onClick={() => act({ type: 'choose', cardIds: [pick[0], pick[1]] })}
|
||||
>
|
||||
{preview ? `${preview.name}(으)로 정하기` : '두 장을 고르세요'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{v.handPhase === 'choose' && seat.chosen === true && <p className="small muted" style={{ margin: 0, textAlign: 'center' }}>골랐어요. 다른 사람을 기다리는 중이에요.</p>}
|
||||
</div>
|
||||
)}
|
||||
{seat.inHand && v.handPhase === 'betting' && !seat.folded && (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<div className="sd-actions" role="group" aria-label="베팅">
|
||||
{BET_ORDER.filter((t) => t !== 'full' || v.options.maxRaise === 'full').map((t) => {
|
||||
const l = legal.get(t);
|
||||
const enabled = myTurn && !!l && !pending;
|
||||
const cls = t === 'die' ? 'btn-danger' : t === 'check' || t === 'call' ? 'btn-primary' : 'btn-soft';
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
className={`btn ${cls}`}
|
||||
disabled={!enabled}
|
||||
title={BET_HELP[t]}
|
||||
onMouseEnter={() => setHover(t)}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
onFocus={() => setHover(t)}
|
||||
onBlur={() => setHover(null)}
|
||||
onClick={() => act({ type: t })}
|
||||
>
|
||||
<span>{BET_KO[t]}</span>
|
||||
<small>{l && l.amount > 0 ? `${formatChips(l.amount)}${l.allIn ? ' 올인' : ''}` : t === 'die' ? '포기' : l ? '0 칩' : '-'}</small>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="small muted" style={{ margin: 0, minHeight: '1.4em', textAlign: 'center' }}>
|
||||
{myTurn
|
||||
? hoverBet
|
||||
? `${BET_KO[hover!]}: ${BET_HELP[hover!]}. 내고 나면 내 칩 ${formatChips(seat.stack - hoverBet.amount)}, 모인 칩 ${formatChips(v.pot + hoverBet.amount)}`
|
||||
: '내 차례예요. 버튼에 적힌 칩이 바로 나가요.'
|
||||
: v.toAct
|
||||
? `${nick(players, v.toAct)}님 차례예요.`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{v.handPhase === 'handEnd' && <ResultPanel view={v} players={players} />}
|
||||
{v.handPhase === 'handEnd' && seat.status === 'playing' && !seat.left && (
|
||||
<button type="button" className="btn btn-primary btn-block" disabled={seat.ready || pending} onClick={() => act({ type: 'ready' })}>
|
||||
{seat.ready ? '다른 사람을 기다리는 중…' : v.next === 'rematch' ? '재경기 시작' : '다음 판으로'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultPanel({ view: v, players }: { view: SeotdaView; players: Record<string, PublicUser> }) {
|
||||
return (
|
||||
<div className="card stack" style={{ gap: 6, padding: 12 }} aria-live="polite">
|
||||
<strong>{v.next === 'rematch' ? '재경기가 결정됐어요' : '이번 판 결과'}</strong>
|
||||
{v.foldWin && (
|
||||
<span>
|
||||
{nick(players, v.foldWin.winner)}님이 {formatChips(v.foldWin.amount)}을 가져갔어요. 모두 다이해서 패는 공개하지 않아요.
|
||||
</span>
|
||||
)}
|
||||
{v.showdown?.pots.map((p, i) => {
|
||||
const who = p.winners.map((id) => nick(players, id)).join(' · ');
|
||||
const name = v.showdown!.hands.find((h) => h.id === p.winners[0])?.name;
|
||||
return (
|
||||
<span key={i}>
|
||||
{v.showdown!.pots.length > 1 ? `${i === v.showdown!.pots.length - 1 ? '메인' : '사이드'} ${formatChips(p.amount)}: ` : `${formatChips(p.amount)}: `}
|
||||
{p.type === 'win' && (
|
||||
<>
|
||||
<strong>{who}</strong> 승리 ({name}
|
||||
{p.reason === 'ttaengjabi' || p.reason === 'amhaeng' ? ' 발동' : ''})
|
||||
</>
|
||||
)}
|
||||
{p.type === 'split' && (
|
||||
<>
|
||||
<strong>{who}</strong> 나눠 가짐{p.reason === 'limit' ? ' (재경기 횟수 초과)' : ' (같은 족보)'}
|
||||
</>
|
||||
)}
|
||||
{p.type === 'rematch' && (
|
||||
<>
|
||||
<strong>{REASON_KO[p.reason]}</strong> — {who}님이 다시 겨뤄요. 칩은 그대로 남아요.
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- side
|
||||
|
||||
interface JokboRow {
|
||||
name: string;
|
||||
cards: [number, boolean][];
|
||||
note: string;
|
||||
match: (value: number | null, special: SpecialKind | null) => boolean;
|
||||
}
|
||||
|
||||
function jokboRows(o: SeotdaOptions): JokboRow[] {
|
||||
const v = (lo: number, hi = lo) => (x: number | null, sp: SpecialKind | null) => !sp && x !== null && x >= lo && x <= hi;
|
||||
const rows: JokboRow[] = [
|
||||
{ name: '38광땡', cards: [[3, true], [8, true]], note: '가장 센 패', match: v(1000) },
|
||||
...(o.gwangTtaeng === 'all'
|
||||
? [
|
||||
{ name: '18광땡', cards: [[1, true], [8, true]], note: '', match: v(990) } as JokboRow,
|
||||
{ name: '13광땡', cards: [[1, true], [3, true]], note: '', match: v(980) } as JokboRow,
|
||||
]
|
||||
: []),
|
||||
{ name: '장땡(10땡)', cards: [[10, true], [10, false]], note: '같은 월 두 장 중 최고', match: v(910) },
|
||||
{ name: '9땡 ~ 삥땡(1땡)', cards: [[9, true], [9, false]], note: '숫자가 클수록 셈', match: v(901, 909) },
|
||||
{ name: '알리', cards: [[1, true], [2, false]], note: '1 + 2', match: v(806) },
|
||||
{ name: '독사', cards: [[1, true], [4, false]], note: '1 + 4', match: v(805) },
|
||||
{ name: '구삥', cards: [[1, true], [9, false]], note: '1 + 9', match: v(804) },
|
||||
{ name: '장삥', cards: [[1, true], [10, false]], note: '1 + 10', match: v(803) },
|
||||
{ name: '장사', cards: [[4, false], [10, false]], note: '4 + 10', match: v(802) },
|
||||
{ name: '세륙', cards: [[4, false], [6, false]], note: '4 + 6', match: v(801) },
|
||||
{ name: '갑오(9끗)', cards: [[2, false], [7, false]], note: '두 월을 더한 일의 자리 9', match: v(9) },
|
||||
{ name: '8끗 ~ 1끗', cards: [[3, false], [5, false]], note: '일의 자리가 클수록 셈', match: v(1, 8) },
|
||||
{ name: '망통(0끗)', cards: [[2, false], [8, false]], note: '가장 약한 패', match: v(0) },
|
||||
];
|
||||
const sp = (k: SpecialKind) => (_x: number | null, s: SpecialKind | null) => s === k;
|
||||
if (o.ttaengjabi !== 'off')
|
||||
rows.push({
|
||||
name: '땡잡이',
|
||||
cards: [[3, true], [7, true]],
|
||||
note: `${o.ttaengjabi === 'strict' ? '3월 광 + 7월 열끗' : '3월 + 7월 아무 패'}. 상대 최고가 1~9땡이면 이김, 아니면 망통`,
|
||||
match: sp('ttaengjabi'),
|
||||
});
|
||||
if (o.amhaeng) rows.push({ name: '암행어사', cards: [[4, true], [7, true]], note: '4·7월 열끗. 13·18광땡을 잡음, 아니면 1끗', match: sp('amhaeng') });
|
||||
if (o.gusa) {
|
||||
rows.push({ name: '구사', cards: [[4, false], [9, true]], note: '4 + 9. 상대가 알리 이하면 재경기, 아니면 3끗', match: sp('gusa') });
|
||||
if (o.mungGusa !== 'off') {
|
||||
const upTo = o.mungGusa === 'upTo9' ? '9땡' : o.mungGusa === 'upToJang' ? '장땡' : '8땡';
|
||||
rows.push({ name: '멍텅구리구사', cards: [[4, true], [9, true]], note: `4·9월 열끗. 상대가 ${upTo} 이하면 재경기`, match: sp('mungGusa') });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function JokboTable({ options, mine }: { options: SeotdaOptions; mine?: SeotdaView['myHand'] }) {
|
||||
const rows = jokboRows(options);
|
||||
return (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.9rem' }}>
|
||||
<tbody>
|
||||
{rows.map((r) => {
|
||||
const hit = !!mine && options.hints && r.match(mine.value, mine.special);
|
||||
return (
|
||||
<tr key={r.name} style={{ background: hit ? 'var(--warning-soft)' : undefined, borderBottom: '1px solid var(--border)' }} aria-current={hit ? 'true' : undefined}>
|
||||
<td style={{ padding: '4px 2px', whiteSpace: 'nowrap' }}>
|
||||
{r.cards.map(([m, sp], i) => (
|
||||
<HwatuCard key={i} card={illus(m, sp)} size="sm" style={{ marginRight: 2 }} />
|
||||
))}
|
||||
</td>
|
||||
<td style={{ padding: '4px 6px' }}>
|
||||
<strong>{r.name}</strong>
|
||||
{hit && <span className="sd-badge" style={{ background: 'var(--warning)', color: '#fff', marginLeft: 4 }}>내 패</span>}
|
||||
{r.note && <div className="small muted">{r.note}</div>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function SeotdaSide({ view: v, me, players, act, pending, deadline }: BoardProps<SeotdaView>) {
|
||||
const [confirmQuit, setConfirmQuit] = useState(false);
|
||||
const ranked = [...v.seats].sort((a, b) => b.stack - a.stack);
|
||||
return (
|
||||
<>
|
||||
<style>{CSS}</style>
|
||||
<PlayChipNotice />
|
||||
<div className="card stack" style={{ gap: 6, padding: 12 }}>
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<strong>
|
||||
{v.phase === 'finished' ? '게임 끝' : `${Math.max(1, v.handNo)}판${v.options.endCondition === 'hands' ? ` / ${v.options.handLimit}판` : ''}`}
|
||||
{v.rematchNo > 0 ? ` · 재경기 ${v.rematchNo}회차` : ''}
|
||||
</strong>
|
||||
{v.phase !== 'finished' && <TurnTimer deadline={deadline} totalMs={totalMsOf(v)} mine={isMyTurn(v, me)} size={36} />}
|
||||
</div>
|
||||
<span className="small muted">
|
||||
학교 {formatChips(v.baseUnit)} · {v.options.cardMode === 'three' ? '3장 섯다' : '2장 섯다'} · {v.options.maxRaise === 'full' ? '풀까지' : '하프까지'}
|
||||
</span>
|
||||
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }}>
|
||||
{ranked.map((s) => (
|
||||
<li key={s.id} className="row" style={{ gap: 6, flexWrap: 'nowrap', opacity: s.status === 'eliminated' ? 0.55 : 1 }}>
|
||||
<Avatar user={userOf(players, s.id)} size={24} />
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: s.id === me ? 800 : 600 }}>
|
||||
{nick(players, s.id)}
|
||||
{s.isDealer ? ' · 선' : ''}
|
||||
{s.rebuys > 0 ? ` · 리바이 ${s.rebuys}` : ''}
|
||||
{s.status === 'eliminated' ? ' · 탈락' : s.left ? ' · 나감' : s.away ? ' · 자리 비움' : ''}
|
||||
</span>
|
||||
<Chips n={s.stack} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
{v.result && (
|
||||
<div className="card stack" style={{ gap: 4, padding: 12 }}>
|
||||
<strong>최종 순위</strong>
|
||||
{v.result.ranking.map((g, i) => (
|
||||
<span key={i}>
|
||||
{v.result!.ranking.slice(0, i).reduce((a, x) => a + x.length, 1)}위 {g.map((id) => nick(players, id)).join(', ')}
|
||||
{v.options.endCondition === 'hands' || v.handsPlayed >= 300
|
||||
? ` (${(v.result!.scores[g[0]!] ?? 0) >= 0 ? '+' : ''}${(v.result!.scores[g[0]!] ?? 0).toLocaleString('ko-KR')} 칩)`
|
||||
: ''}
|
||||
</span>
|
||||
))}
|
||||
<span className="small muted">점수 = 마지막 칩 − 시작 칩 × 리바이 횟수</span>
|
||||
</div>
|
||||
)}
|
||||
<details className="card wide-open" open style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>족보표</summary>
|
||||
<JokboTable options={v.options} mine={v.myHand} />
|
||||
</details>
|
||||
{v.history.length > 0 && (
|
||||
<details className="card" style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>판 기록</summary>
|
||||
<ol style={{ listStyle: 'none', margin: '8px 0 0', padding: 0, display: 'grid', gap: 2, maxHeight: 180, overflow: 'auto' }}>
|
||||
{[...v.history].reverse().map((r, i) => (
|
||||
<li key={i} className="small">
|
||||
<span className="muted">
|
||||
{r.handNo}판{r.rematchNo ? `(재${r.rematchNo})` : ''}
|
||||
</span>{' '}
|
||||
{r.how === 'rematch'
|
||||
? `재경기로 넘어감 ${formatChips(r.amount)}`
|
||||
: `${r.winners.map((id) => nick(players, id)).join('·')} ${formatChips(r.amount)}${r.name ? ` · ${r.name}` : ''}${r.how !== 'normal' ? ` · ${REASON_KO[r.how]}` : ''}`}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
)}
|
||||
{v.rejoin?.mine && (
|
||||
<Sheet onClose={() => {}} label="재경기 참가">
|
||||
<h2>구사 재경기에 다시 들어갈까요?</h2>
|
||||
<p className="muted">
|
||||
{formatChips(v.rejoin.cost)}을 모인 칩에 더 내면 재경기에 참가할 수 있어요. 시간이 지나면 참가하지 않아요.
|
||||
</p>
|
||||
<div className="row">
|
||||
<button className="btn btn-primary" style={{ flex: 1 }} disabled={pending || !v.rejoin.canAfford} onClick={() => act({ type: 'rejoin', accept: true })}>
|
||||
{v.rejoin.canAfford ? `${formatChips(v.rejoin.cost)} 내고 참가` : '칩이 모자라요'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} disabled={pending} onClick={() => act({ type: 'rejoin', accept: false })}>
|
||||
참가 안 함
|
||||
</button>
|
||||
</div>
|
||||
</Sheet>
|
||||
)}
|
||||
{v.rebuy?.mine && !confirmQuit && (
|
||||
<Sheet onClose={() => {}} label="칩 다시 받기">
|
||||
<h2>칩을 모두 잃었어요</h2>
|
||||
<p className="muted">
|
||||
놀이용 칩 {formatChips(v.options.startingChips)}을 다시 받고 계속할 수 있어요{v.rebuy.left !== null ? ` (남은 횟수 ${v.rebuy.left}번)` : ''}. 점수에서는 다시 받은 만큼 빠져요.
|
||||
</p>
|
||||
<div className="row">
|
||||
<button className="btn btn-primary" style={{ flex: 1 }} disabled={pending} onClick={() => act({ type: 'rebuy', accept: true })}>
|
||||
칩 다시 받기
|
||||
</button>
|
||||
<button className="btn btn-danger" style={{ flex: 1 }} disabled={pending} onClick={() => setConfirmQuit(true)}>
|
||||
그만하기
|
||||
</button>
|
||||
</div>
|
||||
</Sheet>
|
||||
)}
|
||||
{confirmQuit && v.rebuy?.mine && (
|
||||
<ConfirmSheet
|
||||
title="여기서 그만할까요?"
|
||||
body="그만하면 이번 게임에서 탈락하고 구경만 할 수 있어요."
|
||||
confirm="그만하기"
|
||||
danger
|
||||
onConfirm={() => act({ type: 'rebuy', accept: false })}
|
||||
onClose={() => setConfirmQuit(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- status
|
||||
|
||||
function isMyTurn(v: SeotdaView, me: string | null): boolean {
|
||||
if (!me || v.phase === 'finished') return false;
|
||||
if (v.phase === 'rebuy') return !!v.rebuy?.mine;
|
||||
if (v.handPhase === 'betting') return v.toAct === me;
|
||||
if (v.handPhase === 'choose') return v.seats.find((s) => s.id === me)?.chosen === false;
|
||||
if (v.handPhase === 'rejoin') return !!v.rejoin?.mine;
|
||||
return false;
|
||||
}
|
||||
|
||||
function statusText(v: SeotdaView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
|
||||
if (v.phase === 'rebuy') return v.rebuy?.mine ? '칩을 다시 받을지 골라 주세요' : '칩을 다시 받을지 기다리는 중이에요';
|
||||
switch (v.handPhase) {
|
||||
case 'betting':
|
||||
if (v.toAct === me) return v.myHand && v.options.hints ? `내 차례예요 · 지금: ${v.myHand.name}` : '내 차례예요. 베팅해 주세요';
|
||||
return v.toAct ? `${nick(players, v.toAct)}님 차례예요` : '카드를 나눠 주는 중이에요';
|
||||
case 'choose':
|
||||
return isMyTurn(v, me) ? '쓸 카드 2장을 골라 주세요' : '다른 사람이 카드를 고르는 중이에요';
|
||||
case 'rejoin':
|
||||
return v.rejoin?.mine ? '구사 재경기에 참가할지 골라 주세요' : '다이한 사람이 재경기 참가를 고르는 중이에요';
|
||||
case 'handEnd':
|
||||
return resultLine(v, players) || '판이 끝났어요';
|
||||
default:
|
||||
return '잠시만 기다려 주세요';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 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<SeotdaOptions>) {
|
||||
const [more, setMore] = useState(false);
|
||||
const set = (patch: Partial<SeotdaOptions>) => onChange({ ...o, ...patch });
|
||||
const tooBig = o.baseUnit * 20 > o.startingChips;
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<Field label="카드 수">
|
||||
<Seg value={o.cardMode} options={[['two', '2장 섯다'], ['three', '3장 섯다(최대 6명)']]} onChange={(cardMode) => set({ cardMode })} />
|
||||
</Field>
|
||||
{o.cardMode === 'two' && (
|
||||
<Field label="카드 나눠 주기">
|
||||
<Seg value={o.dealMode} options={[['twoRounds', '1장씩 두 번(베팅 2번)'], ['oneRound', '2장 한 번에(베팅 1번)']]} onChange={(dealMode) => set({ dealMode })} />
|
||||
</Field>
|
||||
)}
|
||||
<Field label="시작 칩">
|
||||
<Seg value={o.startingChips} options={[[500, '500'], [1000, '1,000'], [2000, '2,000'], [5000, '5,000']]} onChange={(startingChips) => set({ startingChips })} />
|
||||
</Field>
|
||||
<Field label="학교(기본 칩) = 삥">
|
||||
<Seg value={o.baseUnit} options={[[2, '2칩'], [10, '10칩'], [20, '20칩'], [50, '50칩']]} onChange={(baseUnit) => set({ baseUnit })} />
|
||||
{tooBig && <span className="field-error">학교는 시작 칩의 20분의 1 이하로 골라 주세요.</span>}
|
||||
</Field>
|
||||
<Field label="끝내는 방식">
|
||||
<Seg value={o.endCondition} options={[['hands', '정해진 판 수'], ['lastStanding', '최후의 1인']]} onChange={(endCondition) => set({ endCondition })} />
|
||||
{o.endCondition === 'hands' && (
|
||||
<Seg value={o.handLimit} options={[[10, '10판'], [20, '20판'], [30, '30판'], [50, '50판']]} onChange={(handLimit) => set({ handLimit })} />
|
||||
)}
|
||||
</Field>
|
||||
<Field label="행동 제한 시간">
|
||||
<Seg value={o.turnSeconds} options={[[15, '15초'], [20, '20초'], [30, '30초'], [60, '60초']]} onChange={(turnSeconds) => set({ turnSeconds })} />
|
||||
</Field>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
|
||||
</button>
|
||||
{more && (
|
||||
<>
|
||||
<Field label="최대 레이즈">
|
||||
<Seg value={o.maxRaise} options={[['half', '하프까지'], ['full', '풀까지']]} onChange={(maxRaise) => set({ maxRaise })} />
|
||||
</Field>
|
||||
<Field label="칩 다시 받기(리바이)">
|
||||
<Seg value={o.rebuy} options={[['off', '안 함(탈락)'], ['limited', '횟수 제한'], ['unlimited', '무제한']]} onChange={(rebuy) => set({ rebuy })} />
|
||||
{o.rebuy === 'limited' && <Seg value={o.rebuyLimit} options={[[1, '1번'], [2, '2번'], [3, '3번']]} onChange={(rebuyLimit) => set({ rebuyLimit })} />}
|
||||
</Field>
|
||||
<Field label="학교 올리기">
|
||||
<Seg value={o.schoolUpEvery} options={[[0, '안 올림'], [5, '5판마다 2배'], [10, '10판마다 2배'], [15, '15판마다 2배']]} onChange={(schoolUpEvery) => set({ schoolUpEvery })} />
|
||||
</Field>
|
||||
<Field label="다음 판의 선">
|
||||
<Seg value={o.dealerRule} options={[['winner', '이긴 사람'], ['rotate', '돌아가며']]} onChange={(dealerRule) => set({ dealerRule })} />
|
||||
</Field>
|
||||
<Field label="광땡 종류">
|
||||
<Seg value={o.gwangTtaeng} options={[['all', '38·18·13광땡'], ['only38', '38광땡만']]} onChange={(gwangTtaeng) => set({ gwangTtaeng })} />
|
||||
</Field>
|
||||
<Field label="땡잡이(3·7)">
|
||||
<Seg value={o.ttaengjabi} options={[['strict', '3월 광 + 7월 열끗'], ['loose', '3월 + 7월 아무 패'], ['off', '없음']]} onChange={(ttaengjabi) => set({ ttaengjabi })} />
|
||||
</Field>
|
||||
<Field label="특수패">
|
||||
<Seg value={o.amhaeng ? 'on' : 'off'} options={[['on', '암행어사 켜기'], ['off', '암행어사 끄기']]} onChange={(k) => set({ amhaeng: k === 'on' })} />
|
||||
<Seg value={o.gusa ? 'on' : 'off'} options={[['on', '구사 재경기 켜기'], ['off', '구사 재경기 끄기']]} onChange={(k) => set({ gusa: k === 'on' })} />
|
||||
</Field>
|
||||
{o.gusa && (
|
||||
<>
|
||||
<Field label="멍텅구리구사 재경기 범위">
|
||||
<Seg value={o.mungGusa} options={[['upTo8', '8땡까지'], ['upTo9', '9땡까지'], ['upToJang', '장땡까지'], ['off', '일반 구사로']]} onChange={(mungGusa) => set({ mungGusa })} />
|
||||
</Field>
|
||||
<Field label="구사 재경기 때 다이한 사람">
|
||||
<Seg value={o.rejoinCost} options={[['none', '참가 불가'], ['half', '모인 칩 절반 내고 참가'], ['full', '모인 칩만큼 내고 참가']]} onChange={(rejoinCost) => set({ rejoinCost })} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<Field label="같은 족보일 때">
|
||||
<Seg value={o.tieRule} options={[['rematch', '재경기'], ['split', '나눠 가짐']]} onChange={(tieRule) => set({ tieRule })} />
|
||||
</Field>
|
||||
<Field label="재경기 최대 횟수(넘으면 나눠 가짐)">
|
||||
<Seg value={o.maxRematches} options={[[1, '1번'], [2, '2번'], [3, '3번'], [5, '5번']]} onChange={(maxRematches) => set({ maxRematches })} />
|
||||
</Field>
|
||||
<Field label="재참가·리바이 답 시간">
|
||||
<Seg value={o.decisionSeconds} options={[[10, '10초'], [15, '15초'], [30, '30초']]} onChange={(decisionSeconds) => set({ decisionSeconds })} />
|
||||
</Field>
|
||||
<Field label="도움 표시">
|
||||
<Seg value={o.hints ? 'on' : 'off'} options={[['on', '내 족보 이름 보여 주기'], ['off', '보여 주지 않기']]} onChange={(k) => set({ hints: k === 'on' })} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function optionsSummary(o: SeotdaOptions): string {
|
||||
return [
|
||||
o.cardMode === 'three' ? '3장 섯다' : o.dealMode === 'twoRounds' ? '2장 섯다(1장씩 두 번)' : '2장 섯다(한 번에)',
|
||||
`시작 ${o.startingChips.toLocaleString('ko-KR')}칩`,
|
||||
`학교 ${o.baseUnit}칩`,
|
||||
o.endCondition === 'hands' ? `${o.handLimit}판` : '최후의 1인',
|
||||
`${o.turnSeconds}초`,
|
||||
].join(' · ');
|
||||
}
|
||||
|
||||
function Rules({ options }: { options?: SeotdaOptions }) {
|
||||
const o = options;
|
||||
return (
|
||||
<div className="stack">
|
||||
<p>
|
||||
화투 20장(1~10월 각 2장)으로 하는 족보 대결이에요. 각자 받은 <strong>2장의 조합(족보)</strong>이 가장 센 사람이 모인 칩을 가져가요. 칩은 이 방에서만 쓰는 놀이용 점수예요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>진행</strong>: 판마다 모두 <strong>학교(기본 칩)</strong>를 내요. 선부터 카드를 받고 차례로 베팅해요.
|
||||
{o?.cardMode === 'three'
|
||||
? ' 3장 섯다는 2장을 받고 베팅 → 1장을 더 받아 3장 중 쓸 2장을 동시에 몰래 고르고 → 마지막 베팅 후 공개해요. 버린 1장은 끝까지 아무도 못 봐요.'
|
||||
: o?.dealMode === 'oneRound'
|
||||
? ' 2장을 한 번에 받고 한 번 베팅한 뒤 공개해요.'
|
||||
: ' 1장 받고 베팅, 1장 더 받고 베팅한 뒤 남은 사람끼리 패를 공개해요.'}{' '}
|
||||
모두 다이하고 한 명만 남으면 그 사람이 패를 보여 주지 않고 가져가요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>베팅</strong>: 체크(안 걸고 넘김), 삥(기본 칩만큼), 콜(앞사람만큼), 따당(앞사람이 건 만큼의 두 배), 쿼터·하프{o?.maxRaise === 'full' ? '·풀' : ''}(모인 칩의 4분의 1·절반
|
||||
{o?.maxRaise === 'full' ? '·전부' : ''}만큼 더), 다이(포기). 한 라운드에 올리기는 한 번뿐이고, 체크나 콜을 한 뒤에는 같은 라운드에서 올릴 수 없어요. 칩이 모자라면 가진 만큼 내고 올인해요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>끗</strong>: 두 장의 월을 더한 값의 일의 자리예요. 7 + 8 = 15 → 5끗, 9 + 10 = 19 → 9끗(갑오), 2 + 8 = 10 → 0끗(망통).
|
||||
</p>
|
||||
<JokboTable options={o ?? ({ gwangTtaeng: 'all', ttaengjabi: 'strict', amhaeng: true, gusa: true, mungGusa: 'upTo8', hints: false } as SeotdaOptions)} />
|
||||
<p>
|
||||
<strong>재경기</strong>: 구사나 같은 족보{o?.tieRule === 'split' ? '(이 방은 동점이면 나눠 가져요)' : ''}로 재경기가 되면 모인 칩은 그대로 두고 남은 사람끼리 새로 카드를 받아요. 학교는 다시 내지 않아요.
|
||||
{o && o.rejoinCost !== 'none' ? ` 구사 재경기 때 다이한 사람은 모인 칩의 ${o.rejoinCost === 'half' ? '절반' : '전부'}만큼 내고 다시 들어올 수 있어요.` : ''} 한 판에서 재경기가{' '}
|
||||
{o?.maxRematches ?? 3}번을 넘으면 나눠 가져요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>선</strong>: 선은 카드를 먼저 받고 먼저 베팅해요. {o?.dealerRule === 'rotate' ? '판마다 다음 사람에게 넘어가요.' : '이긴 사람이 다음 판의 선이 돼요.'}
|
||||
</p>
|
||||
<p>
|
||||
<strong>칩이 떨어지면</strong>:{' '}
|
||||
{o?.rebuy === 'off' ? '탈락하고 구경해요.' : '칩을 다시 받을지 고를 수 있어요. 다시 받은 만큼 최종 점수에서 빠져요.'}{' '}
|
||||
{o?.endCondition === 'lastStanding' ? '칩을 가진 사람이 한 명 남으면 끝나요.' : `정해진 판 수가 끝나면 (마지막 칩 − 시작 칩 × 다시 받은 횟수)로 순위를 정해요.`}
|
||||
</p>
|
||||
<p>시간 안에 고르지 않으면 걸린 칩이 없을 때는 체크, 있을 때는 다이로 처리돼요. 두 번 연속 시간을 넘기면 '자리 비움'이 되고 제한 시간이 5초로 줄어요.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ui: GameUI<SeotdaView, SeotdaOptions> = {
|
||||
Board: SeotdaBoard,
|
||||
Side: SeotdaSide,
|
||||
statusText,
|
||||
isMyTurn,
|
||||
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