- packages/games/src/gostop: 화투 48장 + 보너스패(cards), 족보·국진·최종 공식·박·독박 정산(scoring), 패 돌리기·바닥 정리·광팔기·총통·먹기 판정(쪽/뻑/따닥/쓸/폭탄/흔들기/보너스패)·피 뺏기·고/스톱·나가리· 첫뻑/연뻑/삼뻑(round), validate·view(남의 손패·더미 숨김, 매칭/점수 미리 계산)·시간 초과·나가기(index). 2인 맞고, 3인 고스톱, 4~5인 광팔기, 여러 판 세트(방 점수 제로섬). - 테스트: 문서 §10 체크리스트 전부, 1,000 시드 무작위 진행(카드·점수 보존, onTimeout 합법), engine randomPlayout(누출·JSON·결정성), 정보 누출 속성 테스트. - 화면 apps/web/src/games/gostop: 월별 바닥, 매칭 하이라이트, 두 장 중 고르기, 흔들기·폭탄·고/스톱·총통· 국진·광팔기 선택, 먹은 패 종류별 묶음과 족보 진행, 이벤트 알림, 판 결과 계산 과정, PC·휴대폰·다크 모드. - HwatuCard: 보너스패(2피·3피) 그림 추가. 게임 목록·레지스트리 등록, 홈 "준비 중"에서 고스톱 제거. - E2E: 맞고 PC 방장 + 휴대폰 친구가 실제 화면 조작으로 고·스톱까지 진행. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1175 lines
60 KiB
TypeScript
1175 lines
60 KiB
TypeScript
/** 고스톱 / 맞고 화면 (docs/games/gostop.md §9). Points are room-only play points. */
|
|
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
import type { BakKind, CardView, GostopOptions, GostopView, HandCardView, OutcomeView, SeatView, TurnTag } from '@bg/games/gostop';
|
|
import type { PublicUser } from '@bg/shared/lite';
|
|
import { Avatar, Sheet } from '../../components/ui';
|
|
import { TurnTimer } from '../../components/TurnTimer';
|
|
import { HwatuCard, type HwatuInfo } from '../../components/cards/HwatuCard';
|
|
import { ChipIcon } from '../../components/cards/Chips';
|
|
import { haptic, sounds } from '../../lib/feedback';
|
|
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
|
|
|
const nick = (players: Record<string, PublicUser>, id: string | null | undefined) => (id ? (players[id]?.nickname ?? '알 수 없음') : '');
|
|
const userOf = (players: Record<string, PublicUser>, id: string): PublicUser => players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
|
|
const signed = (n: number) => `${n > 0 ? '+' : ''}${n.toLocaleString('ko-KR')}점`;
|
|
const pts = (n: number) => `${n.toLocaleString('ko-KR')}점`;
|
|
|
|
const NOTICE = '점수는 이 방에서만 쓰는 놀이용 점수예요. 실제 가치가 없고 바꾸거나 주고받을 수 없으며, 방이 끝나면 사라져요.';
|
|
const BAK_KO: Record<BakKind, string> = { pi: '피박 x2', gwang: '광박 x2', meong: '멍박 x2', go: '고박' };
|
|
const TAG_KO: Partial<Record<TurnTag, string>> = {
|
|
jjok: '쪽!',
|
|
ppeok: '뻑!',
|
|
ttadak: '따닥!',
|
|
sseul: '싹쓸이!',
|
|
bomb: '폭탄!',
|
|
shake: '흔들기!',
|
|
ppeokEat: '뻑 먹기!',
|
|
jaPpeok: '자뻑!',
|
|
firstPpeok: '첫뻑! +3점',
|
|
yeonPpeok: '연뻑! +6점',
|
|
samPpeok: '삼뻑!',
|
|
bonus: '보너스패',
|
|
bonusFlip: '보너스패',
|
|
};
|
|
const KIND_LABEL = { gwang: '광', yeol: '열끗', tti: '띠', pi: '피' } as const;
|
|
|
|
function useNarrow(): boolean {
|
|
const q = '(max-width: 719px)';
|
|
const [narrow, setNarrow] = useState(() => typeof matchMedia !== 'undefined' && matchMedia(q).matches);
|
|
useEffect(() => {
|
|
const m = matchMedia(q);
|
|
const on = () => setNarrow(m.matches);
|
|
m.addEventListener('change', on);
|
|
return () => m.removeEventListener('change', on);
|
|
}, []);
|
|
return narrow;
|
|
}
|
|
|
|
function totalMsOf(v: GostopView): number {
|
|
if (v.phase === 'roundResult') return 10_000;
|
|
const who = v.step === 'sell' ? v.sell?.current : v.turn;
|
|
if (who && v.seats.find((s) => s.id === who)?.autoPilot) return 2_000;
|
|
return (v.step === 'play' ? v.rules.turnSeconds : v.rules.decisionSeconds) * 1000;
|
|
}
|
|
|
|
/** Whoever the table is waiting for (null on result screens). */
|
|
function actorOf(v: GostopView): string | null {
|
|
if (v.phase !== 'playing') return null;
|
|
return v.step === 'sell' ? (v.sell?.current ?? null) : v.turn;
|
|
}
|
|
|
|
function isMyTurn(v: GostopView, me: string | null): boolean {
|
|
if (!me || v.phase === 'finished') return false;
|
|
if (v.phase === 'roundResult') return !v.ready.includes(me) && !!v.seats.find((s) => s.id === me && !s.left);
|
|
return actorOf(v) === me;
|
|
}
|
|
|
|
function statusText(v: GostopView, me: string | null, players: Record<string, PublicUser>): string {
|
|
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
|
|
if (v.phase === 'roundResult') return isMyTurn(v, me) ? `${v.roundNo}판이 끝났어요. 다음 판 준비를 눌러 주세요` : '다른 사람이 다음 판을 준비하는 중이에요';
|
|
const who = actorOf(v);
|
|
const mine = who === me && !!me;
|
|
const name = nick(players, who);
|
|
switch (v.step) {
|
|
case 'sell':
|
|
return mine ? '칠지, 죽고 광을 팔지 골라 주세요' : `${name}님이 칠지 고르는 중이에요`;
|
|
case 'chongtong':
|
|
return mine ? '총통이에요! 선언할지 골라 주세요' : `${name}님이 총통을 확인하는 중이에요`;
|
|
case 'play':
|
|
return mine ? '내 차례예요. 낼 패를 골라 주세요' : `${name}님 차례예요`;
|
|
case 'chooseFlip':
|
|
return mine ? '내 차례예요. 가져올 바닥 패를 골라 주세요' : `${name}님이 가져올 패를 고르는 중이에요`;
|
|
case 'goStop':
|
|
return mine ? `내 차례예요. ${v.goStop?.score ?? ''}점! 고 또는 스톱을 골라 주세요` : `${name}님이 고·스톱을 고르는 중이에요`;
|
|
case 'gukjin':
|
|
return mine ? '9월 열끗(국진)을 어떻게 쓸지 골라 주세요' : `${name}님이 국진을 정하는 중이에요`;
|
|
default:
|
|
return '잠시만 기다려 주세요';
|
|
}
|
|
}
|
|
|
|
const CSS = `
|
|
.gs { width: 100%; max-width: 1100px; display: flex; flex-direction: column; gap: 8px; align-self: center; margin: auto 0; }
|
|
.gs-opps { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 8px; list-style: none; margin: 0; padding: 0; }
|
|
.gs-seat { background: var(--surface); color: var(--text); border-radius: 12px; padding: 6px 10px; border: 3px solid transparent;
|
|
box-shadow: var(--shadow); display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
|
.gs-seat.turn { border-color: var(--focus); }
|
|
.gs-seat.out { opacity: .65; }
|
|
.gs-head { display: flex; align-items: center; gap: 6px; min-width: 0; flex-wrap: wrap; }
|
|
.gs-head .nm { font-weight: 800; min-width: 0; max-width: 12em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.gs-head .sp { flex: 1; }
|
|
.gs-badges { display: flex; flex-wrap: wrap; gap: 4px; }
|
|
.gs-badge { display: inline-block; border-radius: 999px; padding: 0 8px; font-weight: 800; font-size: .8rem; line-height: 1.7; white-space: nowrap;
|
|
background: var(--surface-2); color: var(--text); }
|
|
.gs-badge.score { background: var(--primary); color: var(--primary-text); }
|
|
.gs-badge.go { background: #dc2626; color: #fff; }
|
|
.gs-badge.dealer { background: #facc15; color: #422006; }
|
|
.gs-badge.warn { background: var(--warning); color: #fff; }
|
|
.gs-backs { display: inline-flex; align-items: center; gap: 4px; font-weight: 800; font-variant-numeric: tabular-nums; }
|
|
.gs-backs i { display: inline-block; width: 12px; height: 18px; border-radius: 3px; background: #b91c1c; border: 1.5px solid #450a0a; }
|
|
.gs-cap { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: flex-end; padding: 0; border: 0; background: none; color: inherit; text-align: left; cursor: pointer; width: 100%; }
|
|
.gs-cap:disabled { cursor: default; }
|
|
.gs-pile { display: flex; flex-direction: column; gap: 1px; min-width: 40px; }
|
|
.gs-pile b { font-size: .78rem; font-weight: 800; color: var(--text-sub); white-space: nowrap; }
|
|
.gs-pile-cards { display: flex; }
|
|
.gs-pile-cards > * + * { margin-left: var(--ov, -30px); }
|
|
.gs-cap[aria-expanded='true'] .gs-pile-cards > * + * { --ov: -14px; }
|
|
.gs-table { border-radius: 20px; border: 8px solid var(--wood); box-shadow: var(--shadow); color: #fff; padding: 10px;
|
|
background: radial-gradient(ellipse at center, rgba(255,255,255,.12), rgba(255,255,255,0) 65%), var(--felt);
|
|
display: grid; grid-template-columns: minmax(0, 1fr) 210px; gap: 10px; align-items: stretch; }
|
|
.gs-floor { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 6px; list-style: none; margin: 0; padding: 0; }
|
|
.gs-slot { min-height: 104px; border-radius: 10px; border: 2px dashed rgba(255,255,255,.16); display: flex; align-items: center; justify-content: center; position: relative; }
|
|
.gs-slot .m { position: absolute; top: 1px; left: 6px; font-size: .72rem; font-weight: 700; color: rgba(255,255,255,.55); }
|
|
.gs-slot.has { border-style: solid; border-color: rgba(255,255,255,.28); }
|
|
.gs-slot.ppeok { border: 3px solid #f87171; background: rgba(239,68,68,.18); }
|
|
.gs-slot.ppeok::after { content: '뻑'; position: absolute; right: 4px; top: 2px; font-size: .75rem; font-weight: 900; color: #fecaca; }
|
|
.gs-slot.natural::after { content: '3장'; }
|
|
.gs-slot-cards { display: flex; }
|
|
.gs-slot-cards > * + * { margin-left: var(--sov, -40px); }
|
|
.gs-glow { animation: gs-blink .9s ease-in-out infinite; border-radius: 8px; }
|
|
@keyframes gs-blink { 0%, 100% { filter: drop-shadow(0 0 2px #fde047); } 50% { filter: drop-shadow(0 0 10px #fde047) brightness(1.08); } }
|
|
.gs-side { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
|
.gs-deck { display: flex; align-items: center; gap: 10px; justify-content: center; text-align: center; }
|
|
.gs-deck strong { font-size: 1.1rem; }
|
|
.gs-info { font-size: .85rem; opacity: .92; }
|
|
.gs-last { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; font-size: .85rem; min-height: 40px; flex: 1;
|
|
background: rgba(0,0,0,.18); border-radius: 10px; padding: 4px 8px; align-content: center; }
|
|
.gs-last .cards { display: inline-flex; gap: 2px; }
|
|
.gs-me { background: var(--surface); border-radius: 12px; padding: 6px 10px; box-shadow: var(--shadow); display: flex; flex-direction: column; gap: 6px; border: 3px solid transparent; }
|
|
.gs-me.turn { border-color: var(--focus); }
|
|
.gs-prog { display: flex; flex-wrap: wrap; gap: 4px; }
|
|
.gs-prog span { border-radius: 8px; padding: 1px 7px; background: var(--surface-2); font-size: .82rem; font-weight: 700; white-space: nowrap; }
|
|
.gs-prog span.done { background: var(--success-soft); color: var(--success); }
|
|
.gs-hand { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }
|
|
.gs-hcard { position: relative; padding: 0; border: 3px solid transparent; border-radius: 11px; background: none; cursor: pointer; line-height: 0; transition: transform .12s ease; }
|
|
.gs-hcard[data-match='true'] { border-color: #16a34a; box-shadow: 0 0 0 2px rgba(22,163,74,.35); }
|
|
.gs-hcard[data-lifted='true'] { transform: translateY(-10px); border-color: var(--primary); }
|
|
.gs-hcard:disabled { cursor: default; }
|
|
.gs-hcard:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
|
.gs-hcard .tag { position: absolute; top: -9px; right: -7px; z-index: 1; line-height: 1.5; font-size: .72rem; font-weight: 900; border-radius: 999px; padding: 0 6px;
|
|
background: #dc2626; color: #fff; pointer-events: none; }
|
|
.gs-hcard .tag.shake { background: #7c3aed; }
|
|
.gs-hint { margin: 0; text-align: center; font-size: .9rem; color: var(--text-sub); }
|
|
.gs-flash { position: fixed; left: 50%; top: 36%; transform: translate(-50%, -50%); z-index: 30; pointer-events: none; text-align: center;
|
|
font-size: 3rem; font-weight: 900; color: #fde047; text-shadow: 0 3px 10px rgba(0,0,0,.7); animation: gs-pop .35s ease-out; white-space: nowrap; }
|
|
.gs-flash small { display: block; font-size: 1.1rem; color: #fff; }
|
|
@keyframes gs-pop { from { transform: translate(-50%, -50%) scale(.5); opacity: 0 } to { transform: translate(-50%, -50%) scale(1); opacity: 1 } }
|
|
.gs-big { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
|
.gs-big .btn { min-height: 76px; font-size: 1.3rem; flex-direction: column; gap: 2px; line-height: 1.2; }
|
|
.gs-big .btn small { font-size: .85rem; font-weight: 600; opacity: .95; }
|
|
.gs-pick { display: flex; gap: 16px; justify-content: center; flex-wrap: wrap; }
|
|
.gs-pick button { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 8px; border-radius: 12px; border: 3px solid var(--border);
|
|
background: var(--surface); color: var(--text); cursor: pointer; font-weight: 700; }
|
|
.gs-pick button:hover, .gs-pick button:focus-visible { border-color: var(--primary); }
|
|
.gs-panel { background: var(--surface); border-radius: 12px; padding: 12px; box-shadow: var(--shadow); display: flex; flex-direction: column; gap: 8px; }
|
|
.gs-res table { width: 100%; border-collapse: collapse; font-size: .95rem; }
|
|
.gs-res td { padding: 3px 4px; border-bottom: 1px solid var(--border); }
|
|
.gs-res td.n { text-align: right; font-variant-numeric: tabular-nums; font-weight: 700; white-space: nowrap; }
|
|
.gs-notice { background: var(--surface-2); border-radius: 10px; padding: 8px 12px; margin: 0; }
|
|
@media (max-width: 719px) {
|
|
.gs-opps { grid-template-columns: 1fr; }
|
|
.gs-table { grid-template-columns: 1fr; border-width: 5px; padding: 8px; }
|
|
.gs-floor { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 4px; }
|
|
.gs-slot { min-height: 72px; }
|
|
.gs-slot-cards > * + * { --sov: -26px; }
|
|
.gs-me { padding: 6px 4px; }
|
|
.gs-hand { gap: 4px; }
|
|
.gs-hcard { border-width: 2px; }
|
|
.gs-flash { font-size: 2.4rem; }
|
|
}
|
|
`;
|
|
|
|
// ---------------------------------------------------------------- small pieces
|
|
|
|
function Card({ c, size = 'sm', className, style }: { c: CardView | null; size?: 'sm' | 'md' | 'lg'; className?: string; style?: React.CSSProperties }) {
|
|
const card = c as HwatuInfo | null;
|
|
return className ? (
|
|
<span className={className} style={{ display: 'inline-flex', ...style }}>
|
|
<HwatuCard card={card} size={size} />
|
|
</span>
|
|
) : (
|
|
<HwatuCard card={card} size={size} style={style} />
|
|
);
|
|
}
|
|
|
|
function CapturedRow({ seat, label }: { seat: SeatView; label: string }) {
|
|
const [open, setOpen] = useState(false);
|
|
const groups = (['gwang', 'yeol', 'tti', 'pi'] as const).filter((k) => seat.captured[k].length > 0);
|
|
if (groups.length === 0) return <span className="small muted">먹은 패 없음</span>;
|
|
return (
|
|
<button type="button" className="gs-cap" aria-expanded={open} onClick={() => setOpen(!open)} aria-label={`${label} 먹은 패: ${groups.map((k) => `${KIND_LABEL[k]} ${seat.captured[k].length}장`).join(', ')}. 눌러서 ${open ? '접기' : '펼치기'}`}>
|
|
{groups.map((k) => (
|
|
<span key={k} className="gs-pile">
|
|
<b>
|
|
{KIND_LABEL[k]} {k === 'pi' ? `${seat.score.pi}` : seat.captured[k].length}
|
|
</b>
|
|
<span className="gs-pile-cards">
|
|
{seat.captured[k].map((c) => (
|
|
<HwatuCard key={c.key} card={c as HwatuInfo} size="sm" />
|
|
))}
|
|
</span>
|
|
</span>
|
|
))}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function Progress({ seat, winScore }: { seat: SeatView; winScore: number }) {
|
|
const s = seat.score;
|
|
const items: [string, boolean][] = [
|
|
[`광 ${s.gwang}장`, s.gwang >= 3],
|
|
[`열끗 ${s.yeol}/5`, s.yeol >= 5],
|
|
[`띠 ${s.tti}/5`, s.tti >= 5],
|
|
[`피 ${s.pi}/10`, s.pi >= 10],
|
|
];
|
|
if (s.godori) items.push([`고도리 ${s.godori}/3`, s.godori === 3]);
|
|
if (s.hong) items.push([`홍단 ${s.hong}/3`, s.hong === 3]);
|
|
if (s.cheong) items.push([`청단 ${s.cheong}/3`, s.cheong === 3]);
|
|
if (s.cho) items.push([`초단 ${s.cho}/3`, s.cho === 3]);
|
|
return (
|
|
<div className="gs-prog" aria-label={`족보 진행: ${items.map((x) => x[0]).join(', ')}. 지금 ${s.total}점, 기준 ${winScore}점`}>
|
|
{items.map(([t, done]) => (
|
|
<span key={t} className={done ? 'done' : undefined}>
|
|
{t}
|
|
</span>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SeatBadges({ seat, v }: { seat: SeatView; v: GostopView }) {
|
|
return (
|
|
<div className="gs-badges">
|
|
{seat.participant && <span className="gs-badge score" title="이번 판 지금 점수">지금 {seat.score.total}점</span>}
|
|
{seat.goCount > 0 && <span className="gs-badge go">{seat.goCount}고</span>}
|
|
{seat.shakes > 0 && <span className="gs-badge">흔듦 {seat.shakes}</span>}
|
|
{seat.bombs > 0 && <span className="gs-badge">폭탄 {seat.bombs}</span>}
|
|
{seat.ppeok > 0 && <span className="gs-badge">뻑 {seat.ppeok}</span>}
|
|
{seat.dummies > 0 && <span className="gs-badge">빈패 {seat.dummies}</span>}
|
|
{seat.seller && <span className="gs-badge warn">광 팔기 {signed(seat.seller.paid)}</span>}
|
|
{!seat.participant && !seat.seller && !seat.isDealer && v.step === 'sell' && <span className="gs-badge">고르는 중</span>}
|
|
{seat.left ? <span className="gs-badge warn">나감</span> : seat.autoPilot ? <span className="gs-badge warn">자동</span> : null}
|
|
{v.phase === 'roundResult' && seat.ready && <span className="gs-badge">준비</span>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SeatBox({ seat, v, players, me, deadline }: { seat: SeatView; v: GostopView; players: Record<string, PublicUser>; me: string | null; deadline: number | null }) {
|
|
const u = userOf(players, seat.id);
|
|
const active = actorOf(v) === seat.id;
|
|
return (
|
|
<li className={`gs-seat${active ? ' turn' : ''}${!seat.participant && v.step !== 'sell' ? ' out' : ''}`} aria-label={`${u.nickname}, 방 점수 ${signed(seat.total)}${seat.isDealer ? ', 선' : ''}${seat.participant ? `, 지금 ${seat.score.total}점, 손패 ${seat.handCount}장` : ''}`}>
|
|
<div className="gs-head">
|
|
<Avatar user={u} size={26} />
|
|
<span className="nm">
|
|
{u.nickname}
|
|
{seat.id === me && <span className="muted"> (나)</span>}
|
|
</span>
|
|
{seat.isDealer && <span className="gs-badge dealer">선</span>}
|
|
<span style={{ fontVariantNumeric: 'tabular-nums' }} title="방 점수 합계">
|
|
합계 <strong>{signed(seat.total)}</strong>
|
|
</span>
|
|
{(seat.participant || v.step === 'sell') && (
|
|
<span className="gs-backs" aria-label={`손패 ${seat.handCount}장`}>
|
|
<i aria-hidden /> {seat.handCount}
|
|
</span>
|
|
)}
|
|
<SeatBadges seat={seat} v={v} />
|
|
<span className="sp" />
|
|
{active && <TurnTimer deadline={deadline} totalMs={totalMsOf(v)} mine={false} size={28} />}
|
|
</div>
|
|
{seat.revealed.length > 0 && (
|
|
<div className="row" style={{ gap: 4, alignItems: 'center' }} aria-label="흔들어서 공개한 패">
|
|
<span className="small muted">공개</span>
|
|
{seat.revealed.map((c) => (
|
|
<Card key={c.key} c={c} />
|
|
))}
|
|
</div>
|
|
)}
|
|
{seat.seller && seat.seller.shown.length > 0 && (
|
|
<div className="row" style={{ gap: 4, alignItems: 'center' }} aria-label="광 팔기로 공개한 패">
|
|
<span className="small muted">판 패</span>
|
|
{seat.seller.shown.map((c) => (
|
|
<Card key={c.key} c={c} />
|
|
))}
|
|
</div>
|
|
)}
|
|
{seat.participant && <CapturedRow seat={seat} label={u.nickname} />}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- table
|
|
|
|
function Floor({ v, glow, narrow, players }: { v: GostopView; glow: Set<number>; narrow: boolean; players: Record<string, PublicUser> }) {
|
|
const months = Array.from({ length: 12 }, (_, i) => i + 1);
|
|
const pf = v.pendingFlip;
|
|
return (
|
|
<div className="gs-table" role="region" aria-label="바닥">
|
|
<ul className="gs-floor" aria-label="바닥 패(월별)">
|
|
{months.map((m) => {
|
|
const g = v.floor.find((x) => x.month === m);
|
|
const cls = `gs-slot${g ? ' has' : ''}${g && g.cards.length === 3 ? (g.natural ? ' ppeok natural' : ' ppeok') : ''}`;
|
|
const label = g
|
|
? `${m}월 ${g.cards.length}장${g.cards.length === 3 ? (g.natural ? ', 처음 깔린 3장' : `, ${nick(players, g.ppeokBy)}님이 싼 뻑`) : ''}: ${g.cards.map((c) => c.name).join(', ')}`
|
|
: `${m}월 없음`;
|
|
return (
|
|
<li key={m} className={cls} aria-label={label}>
|
|
<span className="m" aria-hidden>
|
|
{m}월
|
|
</span>
|
|
{g && (
|
|
<span className="gs-slot-cards">
|
|
{g.cards.map((c) => (
|
|
<Card key={c.key} c={c} size={narrow ? 'sm' : 'md'} className={glow.has(c.id) ? 'gs-glow' : undefined} />
|
|
))}
|
|
</span>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
<div className="gs-side">
|
|
<div className="gs-deck">
|
|
<div aria-label={`더미 ${v.deckCount}장`} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
|
<HwatuCard card={null} size={narrow ? 'sm' : 'md'} />
|
|
<strong>{v.deckCount}장</strong>
|
|
</div>
|
|
<div className="gs-info">
|
|
{v.roundNo}/{v.rules.rounds}판
|
|
<br />
|
|
{v.mode === 'matgo' ? '맞고' : '고스톱'} · {v.rules.winScore}점 나기
|
|
{v.nagariMult > 1 && (
|
|
<>
|
|
<br />
|
|
<span className="gs-badge warn">나가리 x{v.nagariMult}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
{pf && (
|
|
<div aria-label={`뒤집은 패 ${pf.flipped.name}`} style={{ textAlign: 'center' }}>
|
|
<div className="small">뒤집은 패</div>
|
|
<Card c={pf.flipped} size="sm" className="gs-glow" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<LastTurn v={v} players={players} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LastTurn({ v, players }: { v: GostopView; players: Record<string, PublicUser> }) {
|
|
const t = v.lastTurn;
|
|
if (!t) return <div className="gs-last" aria-hidden />;
|
|
const tags = t.tags.map((x) => TAG_KO[x]).filter(Boolean);
|
|
const stolen = t.stolen.reduce((a, s) => a + s.cards.length, 0);
|
|
return (
|
|
<div className="gs-last" aria-label="마지막 턴">
|
|
<strong>{nick(players, t.player)}</strong>
|
|
{t.played.length > 0 && (
|
|
<>
|
|
<span>냄</span>
|
|
<span className="cards">
|
|
{t.played.map((c) => (
|
|
<Card key={c.key} c={c} />
|
|
))}
|
|
</span>
|
|
</>
|
|
)}
|
|
{t.flipped.length > 0 && (
|
|
<>
|
|
<span>뒤집음</span>
|
|
<span className="cards">
|
|
{t.flipped.map((c) => (
|
|
<Card key={c.key} c={c} />
|
|
))}
|
|
</span>
|
|
</>
|
|
)}
|
|
<span>{t.captured.length > 0 ? `· ${t.captured.length}장 먹음` : '· 못 먹음'}</span>
|
|
{tags.length > 0 && <span className="gs-badge go">{tags.join(' ')}</span>}
|
|
{stolen > 0 && <span className="gs-badge">피 {stolen}장 뺏음</span>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function flashText(v: GostopView, players: Record<string, PublicUser>): { big: string; small: string } | null {
|
|
const t = v.lastTurn;
|
|
if (!t) return null;
|
|
const order: TurnTag[] = ['samPpeok', 'yeonPpeok', 'firstPpeok', 'bomb', 'ttadak', 'jaPpeok', 'ppeokEat', 'ppeok', 'jjok', 'sseul', 'shake', 'bonus', 'bonusFlip'];
|
|
// 첫뻑·연뻑·삼뻑 already say 뻑.
|
|
const special = t.tags.some((x) => x === 'firstPpeok' || x === 'yeonPpeok' || x === 'samPpeok');
|
|
const tags = order.filter((x) => t.tags.includes(x) && !(special && x === 'ppeok'));
|
|
if (!tags.length && !t.stolen.length) return null;
|
|
const big = tags.length ? tags.map((x) => TAG_KO[x]).slice(0, 2).join(' ') : '피 뺏기';
|
|
const stolen = t.stolen.reduce((a, s) => a + s.cards.length, 0);
|
|
return { big, small: `${nick(players, t.player)}님${stolen ? ` · 피 ${stolen}장 뺏음` : ''}` };
|
|
}
|
|
|
|
// ---------------------------------------------------------------- my area
|
|
|
|
type Ask = { kind: 'target' | 'shake' | 'bomb'; h: HandCardView } | null;
|
|
|
|
function MyHand({ v, act, pending, tapConfirm, onFocusCard }: { v: GostopView; act: (a: unknown) => void; pending: boolean; tapConfirm: boolean; onFocusCard: (h: HandCardView | null) => void }) {
|
|
const [lifted, setLifted] = useState<number | null>(null);
|
|
const [ask, setAsk] = useState<Ask>(null);
|
|
const canPlay = v.step === 'play' && v.turn === v.me && !pending;
|
|
const hints = v.rules.hints;
|
|
useEffect(() => {
|
|
setLifted(null);
|
|
setAsk(null);
|
|
}, [v.turn, v.step, v.roundNo, v.lastTurn?.seq]);
|
|
const hand = v.myHand ?? [];
|
|
const send = (a: object) => {
|
|
sounds.stone();
|
|
act({ type: 'play', ...a });
|
|
setLifted(null);
|
|
setAsk(null);
|
|
};
|
|
const tap = (h: HandCardView) => {
|
|
if (!canPlay) return;
|
|
if (tapConfirm && lifted !== h.card.id) {
|
|
setLifted(h.card.id);
|
|
onFocusCard(h);
|
|
return;
|
|
}
|
|
if (h.card.kind === 'bonus') send({ card: h.card.id });
|
|
else if (h.canBomb) setAsk({ kind: 'bomb', h });
|
|
else if (h.canShake) setAsk({ kind: 'shake', h });
|
|
else if (h.needTarget) setAsk({ kind: 'target', h });
|
|
else send({ card: h.card.id });
|
|
};
|
|
const liftedCard = hand.find((h) => h.card.id === lifted);
|
|
const floorCards = (ids: number[]) => v.floor.flatMap((g) => g.cards).filter((c) => ids.includes(c.id));
|
|
return (
|
|
<>
|
|
<div className="gs-hand" role="group" aria-label="내 손패">
|
|
{hand.map((h) => {
|
|
const match = hints && h.matches.length > 0;
|
|
const tag = h.canBomb ? '폭탄' : h.canShake ? '흔들기' : null;
|
|
const label = `${h.card.name}${h.matches.length ? `, 바닥 ${h.matches.length}장과 맞음` : ''}${tag ? `, ${tag} 가능` : ''}${h.hint ? `, ${h.hint}` : ''}`;
|
|
return (
|
|
<button
|
|
key={h.card.key}
|
|
type="button"
|
|
className="gs-hcard"
|
|
data-match={match || undefined}
|
|
data-lifted={lifted === h.card.id || undefined}
|
|
disabled={!canPlay}
|
|
aria-label={label}
|
|
aria-pressed={tapConfirm ? lifted === h.card.id : undefined}
|
|
onClick={() => tap(h)}
|
|
onMouseEnter={() => onFocusCard(h)}
|
|
onMouseLeave={() => onFocusCard(null)}
|
|
onFocus={() => onFocusCard(h)}
|
|
onBlur={() => onFocusCard(null)}
|
|
>
|
|
<HwatuCard card={h.card as HwatuInfo} size="md" />
|
|
{tag && <span className={`tag${h.canShake ? ' shake' : ''}`}>{tag}</span>}
|
|
</button>
|
|
);
|
|
})}
|
|
{v.myDummies > 0 && (
|
|
<button type="button" className="btn btn-secondary" disabled={!canPlay} onClick={() => send({ card: 'dummy' })} style={{ alignSelf: 'center' }}>
|
|
빈패 내기 ({v.myDummies}장 남음)
|
|
</button>
|
|
)}
|
|
</div>
|
|
{canPlay && liftedCard && (
|
|
<p className="gs-hint" aria-live="polite">
|
|
{`${liftedCard.card.name}${liftedCard.hint ? ` · ${liftedCard.hint}` : ''} — 한 번 더 누르면 내요`}
|
|
</p>
|
|
)}
|
|
{ask && (
|
|
<Sheet onClose={() => setAsk(null)} label={ask.kind === 'target' ? '가져올 패 고르기' : ask.kind === 'shake' ? '흔들기' : '폭탄'}>
|
|
{ask.kind === 'target' && (
|
|
<>
|
|
<h2>어느 패를 가져올까요?</h2>
|
|
<p className="muted">{ask.h.card.name}을(를) 내고 바닥의 같은 월 두 장 중 하나를 가져와요.</p>
|
|
<div className="gs-pick">
|
|
{floorCards(ask.h.matches).map((c) => (
|
|
<button key={c.key} type="button" aria-label={`${c.name} 가져오기`} onClick={() => send({ card: ask.h.card.id, target: c.id })}>
|
|
<HwatuCard card={c as HwatuInfo} size="lg" />
|
|
{c.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
{ask.kind === 'shake' && (
|
|
<>
|
|
<h2>흔들까요?</h2>
|
|
<p className="muted">같은 월 3장을 모두에게 보여 주고 내요. 이번 판을 이기면 점수가 2배가 돼요.</p>
|
|
<div className="gs-big">
|
|
<button type="button" className="btn btn-primary" onClick={() => send({ card: ask.h.card.id, shake: true })}>
|
|
흔들고 내기<small>이기면 x2</small>
|
|
</button>
|
|
<button type="button" className="btn btn-secondary" onClick={() => send({ card: ask.h.card.id })}>
|
|
그냥 내기<small>보여 주지 않아요</small>
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
{ask.kind === 'bomb' && (
|
|
<>
|
|
<h2>폭탄을 할까요?</h2>
|
|
<p className="muted">같은 월 3장을 한꺼번에 내서 바닥 1장까지 4장을 가져오고, 상대마다 피 1장을 받아요. 이기면 x2, 빈패 2장을 받아요.</p>
|
|
<div className="gs-big">
|
|
<button type="button" className="btn btn-primary" onClick={() => send({ card: ask.h.card.id, bomb: true })}>
|
|
폭탄!<small>3장 한꺼번에</small>
|
|
</button>
|
|
<button type="button" className="btn btn-secondary" onClick={() => send({ card: ask.h.card.id })}>
|
|
1장만 내기<small>바닥 1장과 짝</small>
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</Sheet>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function MyArea(props: BoardProps<GostopView> & { onFocusCard: (h: HandCardView | null) => void }) {
|
|
const { view: v, me, players, act, pending, deadline, tapConfirm, onFocusCard } = props;
|
|
const seat = v.seats.find((s) => s.id === me);
|
|
if (!seat || !me) return null;
|
|
const active = actorOf(v) === me;
|
|
if (v.phase !== 'playing') return null;
|
|
return (
|
|
<div className={`gs-me${active ? ' turn' : ''}`} aria-label="내 자리">
|
|
<div className="gs-head">
|
|
<Avatar user={userOf(players, me)} size={26} />
|
|
<span className="nm">나 · 합계 {signed(seat.total)}</span>
|
|
{seat.isDealer && <span className="gs-badge dealer">선</span>}
|
|
<SeatBadges seat={seat} v={v} />
|
|
{seat.participant && <Progress seat={seat} winScore={v.rules.winScore} />}
|
|
<span className="sp" />
|
|
{active && <TurnTimer deadline={deadline} totalMs={totalMsOf(v)} mine size={32} />}
|
|
</div>
|
|
{seat.participant && <CapturedRow seat={seat} label="내" />}
|
|
{seat.revealed.length > 0 && <span className="small muted">흔들어서 공개한 패는 다른 사람도 볼 수 있어요.</span>}
|
|
{v.step === 'sell' && <SellPanel v={v} me={me} players={players} act={act} pending={pending} />}
|
|
{seat.participant && v.myHand && v.step !== 'sell' && <MyHand v={v} act={act} pending={pending} tapConfirm={tapConfirm} onFocusCard={onFocusCard} />}
|
|
{!seat.participant && v.step !== 'sell' && <p className="small muted" style={{ margin: 0 }}>이번 판은 광을 팔고 쉬어요. 다른 사람의 판을 지켜봐요.</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SellPanel({ v, me, players, act, pending }: { v: GostopView; me: string; players: Record<string, PublicUser>; act: (a: unknown) => void; pending: boolean }) {
|
|
const s = v.sell!;
|
|
const mine = s.current === me;
|
|
const payers = v.rules.gwangPayer === 'nonDealerPlayers' ? 2 : 3;
|
|
const n = s.mySellable?.length ?? 0;
|
|
const gain = n * v.rules.gwangPrice * payers;
|
|
return (
|
|
<div className="stack" style={{ gap: 8 }}>
|
|
<strong>광팔기: 3명만 치고 나머지는 죽어요(선은 반드시 쳐요).</strong>
|
|
{s.decisions.length > 0 && (
|
|
<span className="small">
|
|
{s.decisions.map((d) => `${nick(players, d.player)} ${d.sell ? '죽기' : '치기'}${d.auto ? '(자동)' : ''}`).join(' · ')}
|
|
</span>
|
|
)}
|
|
{v.myHand && (
|
|
<div className="row" style={{ gap: 4 }} aria-label="내 손패">
|
|
{v.myHand.map((h) => (
|
|
<HwatuCard key={h.card.key} card={h.card as HwatuInfo} size="sm" highlight={!!s.mySellable?.some((c) => c.id === h.card.id)} />
|
|
))}
|
|
</div>
|
|
)}
|
|
{mine ? (
|
|
<div className="gs-big">
|
|
<button type="button" className="btn btn-primary" disabled={pending} onClick={() => act({ type: 'sell', sell: false })}>
|
|
치기<small>이번 판에 참가해요</small>
|
|
</button>
|
|
<button type="button" className="btn btn-secondary" disabled={pending} onClick={() => act({ type: 'sell', sell: true })}>
|
|
죽기{n > 0 ? ` · 광 ${n}장 팔기` : ''}
|
|
<small>{n > 0 ? `${signed(gain)} 받고 쉬어요` : '팔 패가 없어 그냥 쉬어요'}</small>
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<span className="muted">{s.current ? `${nick(players, s.current)}님이 고르는 중이에요.` : ''}</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- decisions (sheets)
|
|
|
|
function Decisions({ view: v, me, players, act, pending }: BoardProps<GostopView>) {
|
|
if (!me || v.phase !== 'playing') return null;
|
|
const nop = () => {};
|
|
if (v.step === 'goStop' && v.goStop && v.turn === me) {
|
|
const g = v.goStop;
|
|
const baks = (b: BakKind[]) => (b.length ? ` (${b.map((x) => BAK_KO[x]).join(', ')})` : '');
|
|
return (
|
|
<Sheet onClose={nop} label="고 또는 스톱">
|
|
<h2 style={{ textAlign: 'center' }}>{g.score}점 났어요! 고 할까요, 스톱 할까요?</h2>
|
|
<p style={{ textAlign: 'center', margin: 0 }}>
|
|
지금 스톱하면 <strong>{pts(g.stopNow.total)}</strong>을 받아요{g.stopNow.notes.length ? ` · ${g.stopNow.notes.join(', ')}` : ''}
|
|
</p>
|
|
<ul className="small" style={{ margin: 0 }}>
|
|
{g.stopNow.perLoser.map((x) => (
|
|
<li key={x.player}>
|
|
{nick(players, x.player)}님: {pts(x.amount)}
|
|
{baks(x.baks)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
<p className="small muted" style={{ margin: 0 }}>
|
|
고 하면 {g.goNext.goCount}고: +{g.goNext.plus}점{g.goNext.factor > 1 ? `, 점수 x${g.goNext.factor}` : ''}. 다른 사람이 먼저 나면 고박이 될 수 있어요.
|
|
</p>
|
|
<div className="gs-big">
|
|
<button type="button" className="btn btn-danger" disabled={pending} onClick={() => act({ type: 'goStop', decision: 'go' })}>
|
|
고<small>{g.goNext.goCount}고 · 계속해요</small>
|
|
</button>
|
|
<button type="button" className="btn btn-primary" disabled={pending} onClick={() => act({ type: 'goStop', decision: 'stop' })}>
|
|
스톱<small>{pts(g.stopNow.total)} 받고 끝내요</small>
|
|
</button>
|
|
</div>
|
|
</Sheet>
|
|
);
|
|
}
|
|
if (v.step === 'chooseFlip' && v.pendingFlip && v.turn === me) {
|
|
const pf = v.pendingFlip;
|
|
return (
|
|
<Sheet onClose={nop} label="뒤집은 패로 가져올 패 고르기">
|
|
<h2>뒤집은 {pf.flipped.name}과(와) 짝지을 패를 골라 주세요</h2>
|
|
<div className="gs-pick">
|
|
{pf.options.map((c) => (
|
|
<button key={c.key} type="button" disabled={pending} aria-label={`${c.name} 가져오기`} onClick={() => act({ type: 'chooseFlip', target: c.id })}>
|
|
<HwatuCard card={c as HwatuInfo} size="lg" />
|
|
{c.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</Sheet>
|
|
);
|
|
}
|
|
if (v.step === 'chongtong' && v.chongtongAsk?.player === me) {
|
|
const months = v.chongtongAsk.months ?? [];
|
|
const others = v.seats.filter((s) => s.participant && s.id !== me).length;
|
|
return (
|
|
<Sheet onClose={nop} label="총통">
|
|
<h2>총통이에요! ({months.map((m) => `${m}월`).join(', ')} 4장)</h2>
|
|
<p className="muted">선언하면 이번 판을 바로 이기고 상대마다 {pts(v.rules.chongtongPoints * v.nagariMult)}을 받아요. 그냥 치면 그 월로 흔들기를 할 수 있어요.</p>
|
|
<div className="gs-big">
|
|
<button type="button" className="btn btn-primary" disabled={pending} onClick={() => act({ type: 'chongtong', declare: true })}>
|
|
총통 선언<small>{pts(v.rules.chongtongPoints * v.nagariMult * others)} 받기</small>
|
|
</button>
|
|
<button type="button" className="btn btn-secondary" disabled={pending} onClick={() => act({ type: 'chongtong', declare: false })}>
|
|
그냥 치기<small>판을 계속해요</small>
|
|
</button>
|
|
</div>
|
|
</Sheet>
|
|
);
|
|
}
|
|
if (v.step === 'gukjin' && v.gukjinAsk?.player === me) {
|
|
const g = v.gukjinAsk;
|
|
return (
|
|
<Sheet onClose={nop} label="국진 정하기">
|
|
<h2>9월 열끗(국진)을 어떻게 쓸까요?</h2>
|
|
<p className="muted">열끗 1장 또는 쌍피(피 2장 값)로 셀 수 있어요. 시간이 지나면 나에게 유리한 쪽으로 정해져요.</p>
|
|
<div className="gs-big">
|
|
<button type="button" className="btn btn-primary" disabled={pending} onClick={() => act({ type: 'gukjin', as: 'yeol' })}>
|
|
열끗으로<small>내 점수 {g.yeolScore}점</small>
|
|
</button>
|
|
<button type="button" className="btn btn-primary" disabled={pending} onClick={() => act({ type: 'gukjin', as: 'pi' })}>
|
|
쌍피로<small>내 점수 {g.piScore}점</small>
|
|
</button>
|
|
</div>
|
|
</Sheet>
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- round result
|
|
|
|
function outcomeTitle(o: OutcomeView, players: Record<string, PublicUser>, me: string | null): string {
|
|
if (o.type === 'nagari') return '나가리예요';
|
|
const who = o.winner === me ? '내가' : `${nick(players, o.winner)}님이`;
|
|
if (o.type === 'chongtong') return `총통! ${who} 이겼어요`;
|
|
if (o.type === 'samppeok') return `삼뻑! ${who} 이겼어요`;
|
|
return `${who} 스톱해서 이겼어요`;
|
|
}
|
|
|
|
function RoundResult({ view: v, me, players, act, pending }: BoardProps<GostopView>) {
|
|
const o = v.outcome;
|
|
if (!o || v.phase === 'playing') return null;
|
|
const seat = v.seats.find((s) => s.id === me);
|
|
const nextMult = v.rules.nagariMultiplier === 'x2' ? Math.min(2 ** v.nagariStreak, v.rules.nagariMaxMultiplier) : 1;
|
|
return (
|
|
<section className="gs-panel gs-res" aria-label={`${v.roundNo}판 결과`} aria-live="polite">
|
|
<h2 style={{ margin: 0 }}>
|
|
{v.roundNo}판 결과 · {outcomeTitle(o, players, me)}
|
|
</h2>
|
|
{o.type === 'nagari' && <p style={{ margin: 0 }}>아무도 나지 못했어요. 선은 그대로이고 다음 판은 점수 x{nextMult}이에요.</p>}
|
|
{o.shown.length > 0 && (
|
|
<div className="row" style={{ gap: 4 }} aria-label="총통 패">
|
|
{o.shown.map((c) => (
|
|
<Card key={c.key} c={c} />
|
|
))}
|
|
</div>
|
|
)}
|
|
{o.type !== 'nagari' && (
|
|
<table aria-label="점수 계산">
|
|
<tbody>
|
|
{o.breakdown.map((b) => (
|
|
<tr key={b.name}>
|
|
<td>{b.name}</td>
|
|
<td className="n">+{b.points}점</td>
|
|
</tr>
|
|
))}
|
|
<tr>
|
|
<td>
|
|
<strong>합계</strong>
|
|
</td>
|
|
<td className="n">{o.baseScore + (o.type === 'stop' ? o.goCount : 0)}점</td>
|
|
</tr>
|
|
{o.multipliers.map((m) => (
|
|
<tr key={m.name}>
|
|
<td>{m.name}</td>
|
|
<td className="n">x{m.factor}</td>
|
|
</tr>
|
|
))}
|
|
<tr>
|
|
<td>
|
|
<strong>패자 1명당</strong>
|
|
</td>
|
|
<td className="n">{pts(o.perLoser)}</td>
|
|
</tr>
|
|
{o.payments.map((p) => (
|
|
<tr key={p.from}>
|
|
<td>
|
|
{nick(players, p.from)}님이 냄{p.baks.length ? ` · ${p.baks.map((b) => (b === 'go' ? (v.mode === 'matgo' ? '고박 x2' : '독박') : BAK_KO[b])).join(', ')}` : ''}
|
|
{p.coveredBy ? ` · ${nick(players, p.coveredBy)}님이 대신 냄` : ''}
|
|
</td>
|
|
<td className="n">{pts(p.amount)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
<table aria-label="이번 판 점수 변화">
|
|
<tbody>
|
|
{v.seats.map((s) => (
|
|
<tr key={s.id}>
|
|
<td>
|
|
{nick(players, s.id)}
|
|
{s.id === me ? ' (나)' : ''}
|
|
</td>
|
|
<td className="n">{signed(o.delta[s.id] ?? 0)}</td>
|
|
<td className="n muted">합계 {signed(s.total)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{v.phase === 'roundResult' && seat && !seat.left && (
|
|
<button type="button" className="btn btn-primary btn-block" disabled={seat.ready || pending} onClick={() => act({ type: 'ready' })}>
|
|
{seat.ready ? '다른 사람을 기다리는 중…' : '다음 판으로'}
|
|
</button>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- board
|
|
|
|
function GostopBoard(props: BoardProps<GostopView>) {
|
|
const { view: v, me, players } = props;
|
|
const narrow = useNarrow();
|
|
const [focus, setFocus] = useState<HandCardView | null>(null);
|
|
const [flash, setFlash] = useState<{ big: string; small: string } | null>(null);
|
|
const [announce, setAnnounce] = useState('');
|
|
const mine = isMyTurn(v, me);
|
|
const prevMine = useRef(false);
|
|
const seq = `${v.roundNo}-${v.lastTurn?.seq ?? 0}`;
|
|
useEffect(() => {
|
|
const f = flashText(v, players);
|
|
if (v.lastTurn) {
|
|
const t = v.lastTurn;
|
|
setAnnounce(
|
|
`${nick(players, t.player)}님: ${t.played.map((c) => c.name).join(', ')}${t.played.length ? ' 냄, ' : ''}${t.flipped.map((c) => c.name).join(', ')} 뒤집음, ${t.captured.length}장 먹음${f ? `. ${f.big}` : ''}`,
|
|
);
|
|
}
|
|
setFlash(f);
|
|
if (!f) return undefined;
|
|
const tm = setTimeout(() => setFlash(null), 1300);
|
|
return () => clearTimeout(tm);
|
|
}, [seq]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
useEffect(() => {
|
|
if (mine && !prevMine.current && v.phase === 'playing') {
|
|
haptic(40);
|
|
sounds.myTurn();
|
|
setAnnounce('내 차례예요.');
|
|
}
|
|
prevMine.current = mine;
|
|
}, [mine]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
useEffect(() => {
|
|
if (v.outcome && v.phase !== 'playing') {
|
|
if (v.outcome.winner === me) sounds.win();
|
|
setAnnounce(`${v.roundNo}판 결과: ${outcomeTitle(v.outcome, players, me)}`);
|
|
}
|
|
}, [v.roundNo, v.phase]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
const myIdx = v.seats.findIndex((s) => s.id === me);
|
|
const others = myIdx < 0 ? v.seats : [...v.seats.slice(myIdx + 1), ...v.seats.slice(0, myIdx)];
|
|
const glow = new Set<number>(v.rules.hints && focus ? focus.matches : []);
|
|
if (v.pendingFlip) for (const c of v.pendingFlip.options) glow.add(c.id);
|
|
return (
|
|
<div className="gs">
|
|
<style>{CSS}</style>
|
|
<ul className="gs-opps" aria-label={me ? '다른 사람' : '플레이어'}>
|
|
{others.map((s) => (
|
|
<SeatBox key={s.id} seat={s} v={v} players={players} me={me} deadline={props.deadline} />
|
|
))}
|
|
</ul>
|
|
<Floor v={v} glow={glow} narrow={narrow} players={players} />
|
|
<RoundResult {...props} />
|
|
<MyArea {...props} onFocusCard={setFocus} />
|
|
<Decisions {...props} />
|
|
{flash && v.phase === 'playing' && (
|
|
<div className="gs-flash" aria-hidden>
|
|
{flash.big}
|
|
<small>{flash.small}</small>
|
|
</div>
|
|
)}
|
|
<p className="sr-only" aria-live="polite">
|
|
{announce}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- side
|
|
|
|
function GostopSide({ view: v, me, players, deadline }: BoardProps<GostopView>) {
|
|
const ranked = [...v.seats].sort((a, b) => b.total - a.total);
|
|
const mySeat = v.seats.find((s) => s.id === me);
|
|
return (
|
|
<>
|
|
<style>{CSS}</style>
|
|
<p className="small gs-notice">
|
|
<ChipIcon /> {NOTICE}
|
|
</p>
|
|
<div className="card stack" style={{ gap: 6, padding: 12 }}>
|
|
<div className="row" style={{ justifyContent: 'space-between' }}>
|
|
<strong>
|
|
{v.phase === 'finished' ? '게임 끝' : `${v.roundNo}판 / ${v.rules.rounds}판`} · {v.mode === 'matgo' ? '맞고' : v.seats.length > 3 ? '고스톱(광팔기)' : '고스톱'}
|
|
</strong>
|
|
{v.phase !== 'finished' && <TurnTimer deadline={deadline} totalMs={totalMsOf(v)} mine={isMyTurn(v, me)} size={36} />}
|
|
</div>
|
|
<span className="small muted">
|
|
{v.rules.winScore}점부터 고·스톱 · 피박 {v.rules.piBakLimit} 이하{v.nagariMult > 1 ? ` · 이번 판 나가리 x${v.nagariMult}` : ''}
|
|
</span>
|
|
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }} aria-label="방 점수">
|
|
{ranked.map((s) => (
|
|
<li key={s.id} className="row" style={{ gap: 6, flexWrap: 'nowrap' }}>
|
|
<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.left ? ' · 나감' : s.autoPilot ? ' · 자동' : ''}
|
|
</span>
|
|
<strong style={{ fontVariantNumeric: 'tabular-nums' }}>{signed(s.total)}</strong>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</div>
|
|
{mySeat?.participant && v.phase === 'playing' && (
|
|
<div className="card stack" style={{ gap: 6, padding: 12 }}>
|
|
<strong>
|
|
내 점수 {mySeat.score.total}점 / 기준 {v.rules.winScore}점
|
|
</strong>
|
|
{mySeat.score.items.length > 0 ? (
|
|
<span className="small">{mySeat.score.items.map((x) => `${x.name} ${x.points}점`).join(' · ')}</span>
|
|
) : (
|
|
<span className="small muted">아직 점수 족보가 없어요.</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
{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((l) => (
|
|
<li key={l.no} className="small">
|
|
<span className="muted">{l.no}판</span>{' '}
|
|
{l.type === 'nagari' ? '나가리' : `${nick(players, l.winner)} ${pts(l.amount)}${l.type === 'stop' ? '' : l.type === 'chongtong' ? ' · 총통' : ' · 삼뻑'}`}
|
|
{l.breakdown.length > 0 && <span className="muted"> ({l.breakdown.join(', ')})</span>}
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</details>
|
|
)}
|
|
{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(', ')} ({signed(v.result!.scores[g[0]!] ?? 0)})
|
|
</span>
|
|
))}
|
|
{v.stats.bestRound && (
|
|
<span className="small muted">
|
|
가장 큰 판: {v.stats.bestRound.roundNo}판 {nick(players, v.stats.bestRound.player)} {pts(v.stats.bestRound.amount)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
<details className="card" style={{ padding: 12 }}>
|
|
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>족보표</summary>
|
|
<JokboTable rules={v.rules} />
|
|
</details>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- rules & options
|
|
|
|
/** 월별 카드 구성 (§3.1): G 광, Y 열끗, H 홍단, C 청단, O 초단, T 띠(비), P 피, S 쌍피. */
|
|
const MONTH_CODES = ['GHPP', 'YHPP', 'GHPP', 'YOPP', 'YOPP', 'YCPP', 'YOPP', 'GYPP', 'YCPP', 'YCPP', 'GSPP', 'GYTS'];
|
|
const MONTH_NAMES = ['송학', '매조', '벚꽃', '흑싸리', '난초', '모란', '홍싸리', '공산', '국화', '단풍', '오동', '비'];
|
|
|
|
function illus(m: number, slot: number): HwatuInfo {
|
|
const code = MONTH_CODES[m - 1]![slot]!;
|
|
const id = (m - 1) * 4 + slot;
|
|
const base = { id, month: m };
|
|
switch (code) {
|
|
case 'G':
|
|
return { ...base, kind: 'gwang', name: `${m}월 광(${MONTH_NAMES[m - 1]})` };
|
|
case 'Y':
|
|
return { ...base, kind: 'yeol', name: `${m}월 열끗` };
|
|
case 'H':
|
|
return { ...base, kind: 'tti', tti: 'hong', name: `${m}월 홍단` };
|
|
case 'C':
|
|
return { ...base, kind: 'tti', tti: 'cheong', name: `${m}월 청단` };
|
|
case 'O':
|
|
return { ...base, kind: 'tti', tti: 'cho', name: `${m}월 초단` };
|
|
case 'T':
|
|
return { ...base, kind: 'tti', tti: 'plain', name: `${m}월 띠` };
|
|
case 'S':
|
|
return { ...base, kind: 'ssangpi', name: `${m}월 쌍피` };
|
|
default:
|
|
return { ...base, kind: 'pi', name: `${m}월 피` };
|
|
}
|
|
}
|
|
|
|
function JokboTable({ rules }: { rules: Pick<GostopView['rules'], 'ogwangPoints' | 'biGwangThree'> }) {
|
|
const row = (name: string, cards: [number, number][], note: string): ReactNode => (
|
|
<tr key={name} style={{ borderBottom: '1px solid var(--border)' }}>
|
|
<td style={{ padding: '4px 2px', whiteSpace: 'nowrap' }}>
|
|
{cards.map(([m, s]) => (
|
|
<HwatuCard key={`${m}-${s}`} card={illus(m, s)} size="sm" style={{ marginRight: 2 }} />
|
|
))}
|
|
</td>
|
|
<td style={{ padding: '4px 6px' }}>
|
|
<strong>{name}</strong>
|
|
<div className="small muted">{note}</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
return (
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.9rem' }}>
|
|
<tbody>
|
|
{row('오광', [[1, 0], [3, 0], [8, 0], [11, 0], [12, 0]], `광 5장 ${rules.ogwangPoints}점`)}
|
|
{row('사광', [[1, 0], [3, 0], [8, 0], [11, 0]], '광 4장 4점(비광 포함 가능)')}
|
|
{row('삼광', [[1, 0], [3, 0], [8, 0]], `비광 없이 3장 3점 · 비광 끼면 ${rules.biGwangThree ? `${rules.biGwangThree}점` : '점수 없음'}`)}
|
|
{row('고도리', [[2, 0], [4, 0], [8, 1]], '새 3마리 5점(열끗 장수에도 셈)')}
|
|
{row('홍단', [[1, 1], [2, 1], [3, 1]], '3점')}
|
|
{row('청단', [[6, 1], [9, 1], [10, 1]], '3점')}
|
|
{row('초단', [[4, 1], [5, 1], [7, 1]], '3점')}
|
|
{row('열끗', [[5, 0], [6, 0], [7, 0], [10, 0], [12, 1]], '5장 1점, 1장마다 +1점 · 7장이면 멍따 x2')}
|
|
{row('띠', [[1, 1], [4, 1], [6, 1], [7, 1], [12, 2]], '5장 1점, 1장마다 +1점')}
|
|
{row('피', [[1, 2], [11, 1], [12, 3]], '피 값 10에 1점, 1마다 +1점 · 쌍피는 2')}
|
|
</tbody>
|
|
</table>
|
|
);
|
|
}
|
|
|
|
function Rules({ options }: { options?: GostopOptions }) {
|
|
const o = options;
|
|
return (
|
|
<div className="stack">
|
|
<p>
|
|
화투 48장으로 바닥 패와 <strong>같은 월</strong>을 맞춰 가져오고, 가져온 패로 족보를 만들어 점수를 내요. 2명이면 <strong>맞고</strong>(7점부터), 3명이면 <strong>고스톱</strong>(3점부터), 4~5명이면 3명만 치고 나머지는 광을 팔아요. 점수는 이 방에서만 쓰는 놀이용 점수예요.
|
|
</p>
|
|
<p>
|
|
<strong>한 턴</strong>: 손패 1장을 내고 더미에서 1장을 뒤집어요. 낸 패·뒤집은 패가 바닥의 같은 월 패와 맞으면 함께 가져와요. 바닥에 같은 월이 2장이면 하나를 골라요.
|
|
</p>
|
|
<p>
|
|
<strong>특별한 일</strong>: 쪽(낸 패를 바로 뒤집어 먹음), 따닥(바닥 2장 + 손 + 뒤집은 패 4장), 싹쓸이(바닥이 빔)는 상대마다 피 1장을 뺏어요. 뻑은 3장이 바닥에 쌓이고, 4번째 패로 먹으면 피를 뺏어요(내가 싼 뻑을 내가 먹으면 자뻑으로 2장). 첫 턴 뻑은 첫뻑 3점, 한 판에 뻑 3번이면 삼뻑으로 바로 이겨요.
|
|
</p>
|
|
<p>
|
|
<strong>흔들기·폭탄</strong>: 같은 월 3장을 가졌을 때 바닥에 그 월이 없으면 흔들고 낼 수 있고(이기면 x2), 바닥에 1장 있으면 3장을 한꺼번에 내는 폭탄(x2, 피 1장씩, 빈패 2장)을 할 수 있어요.
|
|
</p>
|
|
<p>
|
|
<strong>고·스톱</strong>: 내 점수가 기준 점수 이상이 되면 스톱(끝내고 이김) 또는 고(계속, 1고·2고는 +1점씩, 3고부터 x2, x4…)를 골라요. 고를 한 뒤에는 그때보다 점수가 커져야 다시 고를 수 있어요. 마지막 턴에는 자동으로 스톱해요.
|
|
</p>
|
|
<p>
|
|
<strong>박</strong>: 이긴 사람이 피 점수가 있고 진 사람 피 값이 {!o || o.piBakLimit === 'auto' ? '맞고 7·고스톱 5' : o.piBakLimit} 이하면 피박 x2, 이긴 사람이 광 점수가 있고 진 사람이 광이 없으면 광박 x2, 고를 했다가 지면 고박(맞고 x2, 3명이면 혼자 다 내는 독박)이에요.
|
|
</p>
|
|
<p>
|
|
<strong>나가리</strong>: 아무도 나지 못하면 점수 이동 없이 다음 판 점수가 2배가 돼요(연속이면 누적). 총통(같은 월 4장)은 선언하면 바로 이겨요.
|
|
</p>
|
|
<p>
|
|
<strong>국진</strong>: 9월 열끗은 열끗 또는 쌍피로 쓸 수 있어요. {o?.gukjinMode === 'ask' ? '판이 끝날 때 직접 골라요.' : o?.gukjinMode === 'yeolOnly' ? '이 방은 열끗으로만 써요.' : '점수에 유리한 쪽으로 자동으로 정해요.'}
|
|
</p>
|
|
<strong>월별 패</strong>
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(190px, 1fr))', gap: 6 }}>
|
|
{MONTH_NAMES.map((name, i) => (
|
|
<div key={name} className="row" style={{ gap: 2, flexWrap: 'nowrap', alignItems: 'center' }}>
|
|
<span className="small" style={{ width: 52 }}>
|
|
{i + 1}월 {name}
|
|
</span>
|
|
{[0, 1, 2, 3].map((s) => (
|
|
<HwatuCard key={s} card={illus(i + 1, s)} size="sm" />
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<strong>족보</strong>
|
|
<JokboTable rules={{ ogwangPoints: o?.ogwangPoints ?? 15, biGwangThree: o?.biGwangThree ?? 2 }} />
|
|
<p className="small muted">시간 안에 내지 않으면 바닥과 맞는 패 중 가장 좋은 패를 대신 내고, 고·스톱은 스톱으로 정해요. 두 번 연속 시간을 넘기면 2초 만에 자동으로 진행돼요.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Seg<T extends string | number | boolean>({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) {
|
|
return (
|
|
<div className="segmented" role="group" aria-label={label}>
|
|
{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: ReactNode }) {
|
|
return (
|
|
<div className="stack" style={{ gap: 6 }}>
|
|
<strong>{label}</strong>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const onOff = (label: string, value: boolean, set: (v: boolean) => void, on = '켜기', off = '끄기') => (
|
|
<Field label={label}>
|
|
<Seg label={label} value={value} options={[[true, on], [false, off]]} onChange={set} />
|
|
</Field>
|
|
);
|
|
|
|
function Options({ value: o, onChange }: OptionsProps<GostopOptions>) {
|
|
const [more, setMore] = useState(false);
|
|
const set = (patch: Partial<GostopOptions>) => onChange({ ...o, ...patch });
|
|
return (
|
|
<div className="stack" style={{ gap: 16 }}>
|
|
<p className="small muted" style={{ margin: 0 }}>
|
|
2명이면 맞고, 3명이면 고스톱, 4~5명이면 3명만 치고 나머지는 광을 팔아요.
|
|
</p>
|
|
<Field label="판 수">
|
|
<Seg label="판 수" value={o.rounds} options={[[1, '1판'], [3, '3판'], [5, '5판'], [10, '10판'], [20, '20판']]} onChange={(rounds) => set({ rounds })} />
|
|
</Field>
|
|
<Field label="고·스톱 기준 점수">
|
|
<Seg label="고·스톱 기준 점수" value={o.winScore} options={[['auto', '자동(맞고 7·고스톱 3)'], [3, '3점'], [5, '5점'], [7, '7점']]} onChange={(winScore) => set({ winScore })} />
|
|
</Field>
|
|
<Field label="한 턴 제한 시간">
|
|
<Seg label="한 턴 제한 시간" value={o.turnSeconds} options={[[5, '5초'], [10, '10초'], [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 label="보너스패" value={o.bonusCards} options={[['auto', '자동(맞고만)'], ['none', '없음'], ['2+1', '2피 2장 + 3피 1장'], ['3+1', '2피 3장 + 3피 1장']]} onChange={(bonusCards) => set({ bonusCards })} />
|
|
</Field>
|
|
<Field label="고·스톱·선택 제한 시간">
|
|
<Seg label="고·스톱·선택 제한 시간" value={o.decisionSeconds} options={[[5, '5초'], [10, '10초'], [15, '15초'], [20, '20초'], [30, '30초']]} onChange={(decisionSeconds) => set({ decisionSeconds })} />
|
|
</Field>
|
|
<Field label="피박 기준(진 사람 피 값)">
|
|
<Seg label="피박 기준" value={o.piBakLimit} options={[['auto', '자동(맞고 7·고스톱 5)'], [5, '5 이하'], [6, '6 이하'], [7, '7 이하']]} onChange={(piBakLimit) => set({ piBakLimit })} />
|
|
</Field>
|
|
{onOff('피가 없으면 피박 면제', o.piBakZeroExempt, (piBakZeroExempt) => set({ piBakZeroExempt }), '면제', '피박')}
|
|
{onOff('광박', o.gwangBak, (gwangBak) => set({ gwangBak }))}
|
|
{onOff('멍따(열끗 7장 x2)', o.meongTta, (meongTta) => set({ meongTta }))}
|
|
{onOff('멍박', o.meongBak, (meongBak) => set({ meongBak }))}
|
|
{onOff('고박(맞고 x2, 3명 독박)', o.goBak, (goBak) => set({ goBak }))}
|
|
<Field label="비광 낀 3광">
|
|
<Seg label="비광 낀 3광" value={o.biGwangThree} options={[[2, '2점'], [0, '점수 없음']]} onChange={(biGwangThree) => set({ biGwangThree })} />
|
|
</Field>
|
|
<Field label="5광 점수">
|
|
<Seg label="5광 점수" value={o.ogwangPoints} options={[[15, '15점'], [30, '30점']]} onChange={(ogwangPoints) => set({ ogwangPoints })} />
|
|
</Field>
|
|
<Field label="9월 열끗(국진)">
|
|
<Seg label="국진" value={o.gukjinMode} options={[['auto', '유리한 쪽 자동'], ['ask', '판 끝에 고르기'], ['yeolOnly', '열끗으로만']]} onChange={(gukjinMode) => set({ gukjinMode })} />
|
|
</Field>
|
|
{onOff('흔들기', o.shake, (shake) => set({ shake }))}
|
|
{onOff('폭탄', o.bomb, (bomb) => set({ bomb }))}
|
|
<Field label="총통(같은 월 4장)">
|
|
<Seg label="총통" value={o.chongtong} options={[['win', '즉시 승리'], ['redeal', '다시 돌리기'], ['off', '그냥 진행']]} onChange={(chongtong) => set({ chongtong })} />
|
|
</Field>
|
|
<Field label="총통·삼뻑 점수">
|
|
<Seg label="총통·삼뻑 점수" value={o.chongtongPoints} options={[['auto', '자동(맞고 10·고스톱 5)'], [5, '5점'], [7, '7점'], [10, '10점'], [20, '20점']]} onChange={(chongtongPoints) => set({ chongtongPoints })} />
|
|
</Field>
|
|
{onOff('첫뻑·연뻑·삼뻑', o.ppeokBonus, (ppeokBonus) => set({ ppeokBonus }))}
|
|
<Field label="자뻑 때 뺏는 피">
|
|
<Seg label="자뻑 때 뺏는 피" value={o.jaPpeokSteal} options={[[1, '1장씩'], [2, '2장씩']]} onChange={(jaPpeokSteal) => set({ jaPpeokSteal })} />
|
|
</Field>
|
|
{onOff('마지막 턴 쓸·쪽 피 뺏기', o.lastTurnSweepBonus, (lastTurnSweepBonus) => set({ lastTurnSweepBonus }), '인정', '안 함')}
|
|
{onOff('처음 깔린 3장 먹으면 피 뺏기', o.naturalThreeSteal, (naturalThreeSteal) => set({ naturalThreeSteal }), '뺏기', '안 함')}
|
|
<Field label="나가리 다음 판">
|
|
<Seg label="나가리 다음 판" value={o.nagariMultiplier} options={[['x2', '2배(누적)'], ['none', '배수 없음']]} onChange={(nagariMultiplier) => set({ nagariMultiplier })} />
|
|
{o.nagariMultiplier === 'x2' && (
|
|
<Seg label="나가리 배수 상한" value={o.nagariMaxMultiplier} options={[[2, '최대 x2'], [4, '최대 x4'], [8, '최대 x8']]} onChange={(nagariMaxMultiplier) => set({ nagariMaxMultiplier })} />
|
|
)}
|
|
</Field>
|
|
{onOff('4~5명이면 광팔기', o.gwangPalgi, (gwangPalgi) => set({ gwangPalgi }), '광팔기', '3명까지만')}
|
|
{o.gwangPalgi && (
|
|
<>
|
|
<Field label="광 1장 값">
|
|
<Seg label="광 1장 값" value={o.gwangPrice} options={[[1, '1점'], [2, '2점'], [3, '3점']]} onChange={(gwangPrice) => set({ gwangPrice })} />
|
|
</Field>
|
|
<Field label="광값 내는 사람">
|
|
<Seg label="광값 내는 사람" value={o.gwangPayer} options={[['nonDealerPlayers', '선을 뺀 참가자'], ['allPlayers', '참가자 3명 모두']]} onChange={(gwangPayer) => set({ gwangPayer })} />
|
|
</Field>
|
|
<Field label="팔 수 있는 패">
|
|
<Seg label="팔 수 있는 패" value={o.gwangSellCards} options={[['gwang', '광만'], ['gwangPlus', '광 + 쌍피·국진·보너스']]} onChange={(gwangSellCards) => set({ gwangSellCards })} />
|
|
</Field>
|
|
</>
|
|
)}
|
|
{onOff('먹을 수 있는 패·족보 힌트', o.hints, (hints) => set({ hints }), '보여 주기', '숨기기')}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function optionsSummary(o: GostopOptions): string {
|
|
return [
|
|
`${o.rounds}판`,
|
|
o.winScore === 'auto' ? '맞고 7점·고스톱 3점' : `${o.winScore}점 나기`,
|
|
`${o.turnSeconds}초`,
|
|
o.bonusCards === 'none' ? '보너스패 없음' : o.bonusCards === 'auto' ? '' : `보너스 ${o.bonusCards}`,
|
|
o.gwangPalgi ? '' : '광팔기 없음',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' · ');
|
|
}
|
|
|
|
const ui: GameUI<GostopView, GostopOptions> = {
|
|
Board: GostopBoard,
|
|
Side: GostopSide,
|
|
statusText,
|
|
isMyTurn,
|
|
Options,
|
|
optionsSummary,
|
|
Rules,
|
|
};
|
|
|
|
export default ui;
|