/** 고스톱 / 맞고 화면 (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, id: string | null | undefined) => (id ? (players[id]?.nickname ?? '알 수 없음') : ''); const userOf = (players: Record, 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 = { pi: '피박 x2', gwang: '광박 x2', meong: '멍박 x2', go: '고박' }; const TAG_KO: Partial> = { 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 { 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 ? ( ) : ( ); } 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 먹은 패 없음; return ( ); } 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 (
x[0]).join(', ')}. 지금 ${s.total}점, 기준 ${winScore}점`}> {items.map(([t, done]) => ( {t} ))}
); } function SeatBadges({ seat, v }: { seat: SeatView; v: GostopView }) { return (
{seat.participant && 지금 {seat.score.total}점} {seat.goCount > 0 && {seat.goCount}고} {seat.shakes > 0 && 흔듦 {seat.shakes}} {seat.bombs > 0 && 폭탄 {seat.bombs}} {seat.ppeok > 0 && 뻑 {seat.ppeok}} {seat.dummies > 0 && 빈패 {seat.dummies}} {seat.seller && 광 팔기 {signed(seat.seller.paid)}} {!seat.participant && !seat.seller && !seat.isDealer && v.step === 'sell' && 고르는 중} {seat.left ? 나감 : seat.autoPilot ? 자동 : null} {v.phase === 'roundResult' && seat.ready && 준비}
); } function SeatBox({ seat, v, players, me, deadline }: { seat: SeatView; v: GostopView; players: Record; me: string | null; deadline: number | null }) { const u = userOf(players, seat.id); const active = actorOf(v) === seat.id; return (
  • {u.nickname} {seat.id === me && (나)} {seat.isDealer && 선} 합계 {signed(seat.total)} {(seat.participant || v.step === 'sell') && ( {seat.handCount} )} {active && }
    {seat.revealed.length > 0 && (
    공개 {seat.revealed.map((c) => ( ))}
    )} {seat.seller && seat.seller.shown.length > 0 && (
    판 패 {seat.seller.shown.map((c) => ( ))}
    )} {seat.participant && }
  • ); } // ---------------------------------------------------------------- table function Floor({ v, glow, narrow, players }: { v: GostopView; glow: Set; narrow: boolean; players: Record }) { const months = Array.from({ length: 12 }, (_, i) => i + 1); const pf = v.pendingFlip; return (
      {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 (
    • {m}월 {g && ( {g.cards.map((c) => ( ))} )}
    • ); })}
    {v.deckCount}장
    {v.roundNo}/{v.rules.rounds}판
    {v.mode === 'matgo' ? '맞고' : '고스톱'} · {v.rules.winScore}점 나기 {v.nagariMult > 1 && ( <>
    나가리 x{v.nagariMult} )}
    {pf && (
    뒤집은 패
    )}
    ); } function LastTurn({ v, players }: { v: GostopView; players: Record }) { const t = v.lastTurn; if (!t) return
    ; const tags = t.tags.map((x) => TAG_KO[x]).filter(Boolean); const stolen = t.stolen.reduce((a, s) => a + s.cards.length, 0); return (
    {nick(players, t.player)} {t.played.length > 0 && ( <> 냄 {t.played.map((c) => ( ))} )} {t.flipped.length > 0 && ( <> 뒤집음 {t.flipped.map((c) => ( ))} )} {t.captured.length > 0 ? `· ${t.captured.length}장 먹음` : '· 못 먹음'} {tags.length > 0 && {tags.join(' ')}} {stolen > 0 && 피 {stolen}장 뺏음}
    ); } function flashText(v: GostopView, players: Record): { 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(null); const [ask, setAsk] = useState(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 ( <>
    {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 ( ); })} {v.myDummies > 0 && ( )}
    {canPlay && liftedCard && (

    {`${liftedCard.card.name}${liftedCard.hint ? ` · ${liftedCard.hint}` : ''} — 한 번 더 누르면 내요`}

    )} {ask && ( setAsk(null)} label={ask.kind === 'target' ? '가져올 패 고르기' : ask.kind === 'shake' ? '흔들기' : '폭탄'}> {ask.kind === 'target' && ( <>

    어느 패를 가져올까요?

    {ask.h.card.name}을(를) 내고 바닥의 같은 월 두 장 중 하나를 가져와요.

    {floorCards(ask.h.matches).map((c) => ( ))}
    )} {ask.kind === 'shake' && ( <>

    흔들까요?

    같은 월 3장을 모두에게 보여 주고 내요. 이번 판을 이기면 점수가 2배가 돼요.

    )} {ask.kind === 'bomb' && ( <>

    폭탄을 할까요?

    같은 월 3장을 한꺼번에 내서 바닥 1장까지 4장을 가져오고, 상대마다 피 1장을 받아요. 이기면 x2, 빈패 2장을 받아요.

    )}
    )} ); } function MyArea(props: BoardProps & { 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 (
    나 · 합계 {signed(seat.total)} {seat.isDealer && 선} {seat.participant && } {active && }
    {seat.participant && } {seat.revealed.length > 0 && 흔들어서 공개한 패는 다른 사람도 볼 수 있어요.} {v.step === 'sell' && } {seat.participant && v.myHand && v.step !== 'sell' && } {!seat.participant && v.step !== 'sell' &&

    이번 판은 광을 팔고 쉬어요. 다른 사람의 판을 지켜봐요.

    }
    ); } function SellPanel({ v, me, players, act, pending }: { v: GostopView; me: string; players: Record; 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 (
    광팔기: 3명만 치고 나머지는 죽어요(선은 반드시 쳐요). {s.decisions.length > 0 && ( {s.decisions.map((d) => `${nick(players, d.player)} ${d.sell ? '죽기' : '치기'}${d.auto ? '(자동)' : ''}`).join(' · ')} )} {v.myHand && (
    {v.myHand.map((h) => ( c.id === h.card.id)} /> ))}
    )} {mine ? (
    ) : ( {s.current ? `${nick(players, s.current)}님이 고르는 중이에요.` : ''} )}
    ); } // ---------------------------------------------------------------- decisions (sheets) function Decisions({ view: v, me, players, act, pending }: BoardProps) { 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 (

    {g.score}점 났어요! 고 할까요, 스톱 할까요?

    지금 스톱하면 {pts(g.stopNow.total)}을 받아요{g.stopNow.notes.length ? ` · ${g.stopNow.notes.join(', ')}` : ''}

      {g.stopNow.perLoser.map((x) => (
    • {nick(players, x.player)}님: {pts(x.amount)} {baks(x.baks)}
    • ))}

    고 하면 {g.goNext.goCount}고: +{g.goNext.plus}점{g.goNext.factor > 1 ? `, 점수 x${g.goNext.factor}` : ''}. 다른 사람이 먼저 나면 고박이 될 수 있어요.

    ); } if (v.step === 'chooseFlip' && v.pendingFlip && v.turn === me) { const pf = v.pendingFlip; return (

    뒤집은 {pf.flipped.name}과(와) 짝지을 패를 골라 주세요

    {pf.options.map((c) => ( ))}
    ); } 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 (

    총통이에요! ({months.map((m) => `${m}월`).join(', ')} 4장)

    선언하면 이번 판을 바로 이기고 상대마다 {pts(v.rules.chongtongPoints * v.nagariMult)}을 받아요. 그냥 치면 그 월로 흔들기를 할 수 있어요.

    ); } if (v.step === 'gukjin' && v.gukjinAsk?.player === me) { const g = v.gukjinAsk; return (

    9월 열끗(국진)을 어떻게 쓸까요?

    열끗 1장 또는 쌍피(피 2장 값)로 셀 수 있어요. 시간이 지나면 나에게 유리한 쪽으로 정해져요.

    ); } return null; } // ---------------------------------------------------------------- round result function outcomeTitle(o: OutcomeView, players: Record, 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) { 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 (

    {v.roundNo}판 결과 · {outcomeTitle(o, players, me)}

    {o.type === 'nagari' &&

    아무도 나지 못했어요. 선은 그대로이고 다음 판은 점수 x{nextMult}이에요.

    } {o.shown.length > 0 && (
    {o.shown.map((c) => ( ))}
    )} {o.type !== 'nagari' && ( {o.breakdown.map((b) => ( ))} {o.multipliers.map((m) => ( ))} {o.payments.map((p) => ( ))}
    {b.name} +{b.points}점
    합계 {o.baseScore + (o.type === 'stop' ? o.goCount : 0)}점
    {m.name} x{m.factor}
    패자 1명당 {pts(o.perLoser)}
    {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)}님이 대신 냄` : ''} {pts(p.amount)}
    )} {v.seats.map((s) => ( ))}
    {nick(players, s.id)} {s.id === me ? ' (나)' : ''} {signed(o.delta[s.id] ?? 0)} 합계 {signed(s.total)}
    {v.phase === 'roundResult' && seat && !seat.left && ( )}
    ); } // ---------------------------------------------------------------- board function GostopBoard(props: BoardProps) { const { view: v, me, players } = props; const narrow = useNarrow(); const [focus, setFocus] = useState(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(v.rules.hints && focus ? focus.matches : []); if (v.pendingFlip) for (const c of v.pendingFlip.options) glow.add(c.id); return (
      {others.map((s) => ( ))}
    {flash && v.phase === 'playing' && (
    {flash.big} {flash.small}
    )}

    {announce}

    ); } // ---------------------------------------------------------------- side function GostopSide({ view: v, me, players, deadline }: BoardProps) { const ranked = [...v.seats].sort((a, b) => b.total - a.total); const mySeat = v.seats.find((s) => s.id === me); return ( <>

    {NOTICE}

    {v.phase === 'finished' ? '게임 끝' : `${v.roundNo}판 / ${v.rules.rounds}판`} · {v.mode === 'matgo' ? '맞고' : v.seats.length > 3 ? '고스톱(광팔기)' : '고스톱'} {v.phase !== 'finished' && }
    {v.rules.winScore}점부터 고·스톱 · 피박 {v.rules.piBakLimit} 이하{v.nagariMult > 1 ? ` · 이번 판 나가리 x${v.nagariMult}` : ''}
      {ranked.map((s) => (
    1. {nick(players, s.id)} {s.isDealer ? ' · 선' : ''} {s.left ? ' · 나감' : s.autoPilot ? ' · 자동' : ''} {signed(s.total)}
    2. ))}
    {mySeat?.participant && v.phase === 'playing' && (
    내 점수 {mySeat.score.total}점 / 기준 {v.rules.winScore}점 {mySeat.score.items.length > 0 ? ( {mySeat.score.items.map((x) => `${x.name} ${x.points}점`).join(' · ')} ) : ( 아직 점수 족보가 없어요. )}
    )} {v.log.length > 0 && (
    판 기록
      {[...v.log].reverse().map((l) => (
    1. {l.no}판{' '} {l.type === 'nagari' ? '나가리' : `${nick(players, l.winner)} ${pts(l.amount)}${l.type === 'stop' ? '' : l.type === 'chongtong' ? ' · 총통' : ' · 삼뻑'}`} {l.breakdown.length > 0 && ({l.breakdown.join(', ')})}
    2. ))}
    )} {v.result && (
    최종 순위 {v.result.ranking.map((g, 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)}) ))} {v.stats.bestRound && ( 가장 큰 판: {v.stats.bestRound.roundNo}판 {nick(players, v.stats.bestRound.player)} {pts(v.stats.bestRound.amount)} )}
    )}
    족보표
    ); } // ---------------------------------------------------------------- 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 }) { const row = (name: string, cards: [number, number][], note: string): ReactNode => ( {cards.map(([m, s]) => ( ))} {name}
    {note}
    ); return ( {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')}
    ); } function Rules({ options }: { options?: GostopOptions }) { const o = options; return (

    화투 48장으로 바닥 패와 같은 월을 맞춰 가져오고, 가져온 패로 족보를 만들어 점수를 내요. 2명이면 맞고(7점부터), 3명이면 고스톱(3점부터), 4~5명이면 3명만 치고 나머지는 광을 팔아요. 점수는 이 방에서만 쓰는 놀이용 점수예요.

    한 턴: 손패 1장을 내고 더미에서 1장을 뒤집어요. 낸 패·뒤집은 패가 바닥의 같은 월 패와 맞으면 함께 가져와요. 바닥에 같은 월이 2장이면 하나를 골라요.

    특별한 일: 쪽(낸 패를 바로 뒤집어 먹음), 따닥(바닥 2장 + 손 + 뒤집은 패 4장), 싹쓸이(바닥이 빔)는 상대마다 피 1장을 뺏어요. 뻑은 3장이 바닥에 쌓이고, 4번째 패로 먹으면 피를 뺏어요(내가 싼 뻑을 내가 먹으면 자뻑으로 2장). 첫 턴 뻑은 첫뻑 3점, 한 판에 뻑 3번이면 삼뻑으로 바로 이겨요.

    흔들기·폭탄: 같은 월 3장을 가졌을 때 바닥에 그 월이 없으면 흔들고 낼 수 있고(이기면 x2), 바닥에 1장 있으면 3장을 한꺼번에 내는 폭탄(x2, 피 1장씩, 빈패 2장)을 할 수 있어요.

    고·스톱: 내 점수가 기준 점수 이상이 되면 스톱(끝내고 이김) 또는 고(계속, 1고·2고는 +1점씩, 3고부터 x2, x4…)를 골라요. 고를 한 뒤에는 그때보다 점수가 커져야 다시 고를 수 있어요. 마지막 턴에는 자동으로 스톱해요.

    박: 이긴 사람이 피 점수가 있고 진 사람 피 값이 {!o || o.piBakLimit === 'auto' ? '맞고 7·고스톱 5' : o.piBakLimit} 이하면 피박 x2, 이긴 사람이 광 점수가 있고 진 사람이 광이 없으면 광박 x2, 고를 했다가 지면 고박(맞고 x2, 3명이면 혼자 다 내는 독박)이에요.

    나가리: 아무도 나지 못하면 점수 이동 없이 다음 판 점수가 2배가 돼요(연속이면 누적). 총통(같은 월 4장)은 선언하면 바로 이겨요.

    국진: 9월 열끗은 열끗 또는 쌍피로 쓸 수 있어요. {o?.gukjinMode === 'ask' ? '판이 끝날 때 직접 골라요.' : o?.gukjinMode === 'yeolOnly' ? '이 방은 열끗으로만 써요.' : '점수에 유리한 쪽으로 자동으로 정해요.'}

    월별 패
    {MONTH_NAMES.map((name, i) => (
    {i + 1}월 {name} {[0, 1, 2, 3].map((s) => ( ))}
    ))}
    족보

    시간 안에 내지 않으면 바닥과 맞는 패 중 가장 좋은 패를 대신 내고, 고·스톱은 스톱으로 정해요. 두 번 연속 시간을 넘기면 2초 만에 자동으로 진행돼요.

    ); } function Seg({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) { return (
    {options.map(([k, l]) => ( ))}
    ); } function Field({ label, children }: { label: string; children: ReactNode }) { return (
    {label} {children}
    ); } const onOff = (label: string, value: boolean, set: (v: boolean) => void, on = '켜기', off = '끄기') => ( ); function Options({ value: o, onChange }: OptionsProps) { const [more, setMore] = useState(false); const set = (patch: Partial) => onChange({ ...o, ...patch }); return (

    2명이면 맞고, 3명이면 고스톱, 4~5명이면 3명만 치고 나머지는 광을 팔아요.

    set({ rounds })} /> set({ winScore })} /> set({ turnSeconds })} /> {more && ( <> set({ bonusCards })} /> set({ decisionSeconds })} /> set({ piBakLimit })} /> {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 }))} set({ biGwangThree })} /> set({ ogwangPoints })} /> set({ gukjinMode })} /> {onOff('흔들기', o.shake, (shake) => set({ shake }))} {onOff('폭탄', o.bomb, (bomb) => set({ bomb }))} set({ chongtong })} /> set({ chongtongPoints })} /> {onOff('첫뻑·연뻑·삼뻑', o.ppeokBonus, (ppeokBonus) => set({ ppeokBonus }))} set({ jaPpeokSteal })} /> {onOff('마지막 턴 쓸·쪽 피 뺏기', o.lastTurnSweepBonus, (lastTurnSweepBonus) => set({ lastTurnSweepBonus }), '인정', '안 함')} {onOff('처음 깔린 3장 먹으면 피 뺏기', o.naturalThreeSteal, (naturalThreeSteal) => set({ naturalThreeSteal }), '뺏기', '안 함')} set({ nagariMultiplier })} /> {o.nagariMultiplier === 'x2' && ( set({ nagariMaxMultiplier })} /> )} {onOff('4~5명이면 광팔기', o.gwangPalgi, (gwangPalgi) => set({ gwangPalgi }), '광팔기', '3명까지만')} {o.gwangPalgi && ( <> set({ gwangPrice })} /> set({ gwangPayer })} /> set({ gwangSellCards })} /> )} {onOff('먹을 수 있는 패·족보 힌트', o.hints, (hints) => set({ hints }), '보여 주기', '숨기기')} )}
    ); } 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 = { Board: GostopBoard, Side: GostopSide, statusText, isMyTurn, Options, optionsSummary, Rules, }; export default ui;