/** 바퀴벌레 포커 화면 (docs/games/cockroach-poker.md §9). Critter art is original inline SVG. */ import { useEffect, useRef, useState, type ReactNode } from 'react'; import type { ClaimEntry, CockroachOptions, CockroachView, FrontView, Kind, LastReveal, Phrase } from '@bg/games/cockroach-poker'; import type { PublicUser } from '@bg/shared/lite'; import { Avatar } from '../../components/ui'; import { TurnTimer } from '../../components/TurnTimer'; import { haptic, sounds } from '../../lib/feedback'; import type { BoardProps, GameUI, OptionsProps } from '../types'; // Kept local (type-only import above) so the screen chunk does not pull in the rules module + zod. const KINDS: Kind[] = ['cockroach', 'bat', 'fly', 'toad', 'rat', 'scorpion', 'spider', 'stinkbug']; const KIND_KO: Record = { cockroach: '바퀴벌레', bat: '박쥐', fly: '파리', toad: '두꺼비', rat: '쥐', scorpion: '전갈', spider: '거미', stinkbug: '노린재', }; const PHRASES: Phrase[] = ['really', 'doubt', 'hmm', 'trust']; const PHRASE_KO: Record = { really: '정말이야!', doubt: '의심스러운데?', hmm: '음… 고민되네', trust: '믿어 볼게' }; const nick = (players: Record, id: string) => players[id]?.nickname ?? '알 수 없음'; const userOf = (players: Record, id: string): PublicUser => players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' }; const hasBatchim = (word: string) => { const c = word.charCodeAt(word.length - 1); return c >= 0xac00 && c <= 0xd7a3 && (c - 0xac00) % 28 !== 0; }; /** "이건 쥐야!" / "이건 전갈이야!" */ const claimLine = (k: Kind) => `이건 ${KIND_KO[k]}${hasBatchim(KIND_KO[k]) ? '이야' : '야'}!`; /** Korean particle after a word: j('전갈', '이라고', '라고') → '전갈이라고'. */ const j = (word: string, withBatchim: string, without: string) => `${word}${hasBatchim(word) ? withBatchim : without}`; const kj = (k: Kind, withBatchim: string, without: string) => j(KIND_KO[k], withBatchim, without); function waitingOn(v: CockroachView): string | null { if (v.phase === 'finished') return null; return v.round.holder ?? v.round.starter; } function frontText(f: FrontView, loseCount: number): string { if (f.total === 0) return '앞마당 비어 있음'; return `앞마당 ${f.kinds.map((k) => `${KIND_KO[k]} ${f.counts[k]}장${f.counts[k] >= loseCount - 1 ? '(위험)' : ''}`).join(', ')}`; } // ---------------------------------------------------------------- art const KIND_COLOR: Record = { cockroach: { bg: '#f4e4d0', ink: '#7a4a1e' }, bat: { bg: '#e7def5', ink: '#5b3f8c' }, fly: { bg: '#dce8f7', ink: '#2f5c93' }, toad: { bg: '#ddefd6', ink: '#3e7a2e' }, rat: { bg: '#e6e6ea', ink: '#55555f' }, scorpion: { bg: '#fbe3cf', ink: '#b4561a' }, spider: { bg: '#ece6ef', ink: '#2b2b33' }, stinkbug: { bg: '#d5efec', ink: '#1f6f68' }, }; function Eyes({ x1, x2, y, r = 3.4 }: { x1: number; x2: number; y: number; r?: number }) { return ( ); } /** Simple, cute, original critters (64×64). Each has a distinct silhouette so colour is never the only cue. */ function CritterArt({ kind }: { kind: Kind }) { const c = KIND_COLOR[kind].ink; switch (kind) { case 'cockroach': return ( ); case 'bat': return ( ); case 'fly': return ( ); case 'toad': return ( ); case 'rat': return ( ); case 'scorpion': return ( ); case 'spider': return ( ); case 'stinkbug': return ( ); } } /** Small icon on a light plate of the kind's colour, so dark critters stay visible in dark mode. */ function CritterIcon({ kind, size = 28 }: { kind: Kind; size?: number }) { return ( ); } /** A face-up card: tinted card + critter + name. */ function FaceCard({ kind, size = 'md', fx }: { kind: Kind; size?: 'sm' | 'md' | 'lg'; fx?: string }) { const col = KIND_COLOR[kind]; return ( {KIND_KO[kind]} ); } function BackCard({ size = 'md', fx }: { size?: 'sm' | 'md' | 'lg'; fx?: string }) { return ( ? ); } // ---------------------------------------------------------------- styles const CSS = ` .cp-board { width: 100%; max-width: 1040px; max-height: 100%; overflow: auto; display: flex; flex-direction: column; gap: 12px; padding: 2px; } .cp-seats { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; } .cp-seat { position: relative; background: var(--surface); border-radius: var(--radius-lg); padding: 9px 11px; box-shadow: var(--shadow); border: 3px solid transparent; display: flex; flex-direction: column; gap: 6px; min-width: 0; } .cp-seat.turn { border-color: var(--focus); } .cp-seat.loser { border-color: var(--danger); } .cp-head { display: flex; align-items: center; gap: 7px; min-height: 30px; min-width: 0; } .cp-name { font-weight: 800; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; flex: 1; } .cp-badges { display: flex; gap: 4px; flex-wrap: wrap; } .cp-badge { display: inline-block; border-radius: 999px; padding: 1px 8px; font-weight: 800; font-size: .78rem; background: var(--surface-2); color: var(--text); white-space: nowrap; } .cp-badge.turn { background: var(--focus); color: #1c1e21; } .cp-badge.seen { background: var(--primary-soft); color: var(--text); } .cp-badge.lose { background: var(--danger-soft); color: var(--danger); } .cp-badge.win { background: var(--success-soft); color: var(--success); } .cp-meta { font-size: .85rem; color: var(--text-sub); display: flex; gap: 6px; align-items: center; } .cp-front { display: flex; flex-wrap: wrap; gap: 4px; min-height: 30px; align-items: center; } .cp-chip { display: inline-flex; align-items: center; gap: 2px; border-radius: 9px; padding: 1px 6px 1px 2px; font-weight: 900; font-size: .85rem; border: 2px solid transparent; font-variant-numeric: tabular-nums; } .cp-chip.warn { border-color: var(--warning); background: var(--warning-soft); } .cp-chip.danger { border-color: var(--danger); background: var(--danger-soft); color: var(--danger); } .cp-chip .cp-mark { font-size: .72rem; margin-left: 1px; } .cp-empty { font-size: .82rem; color: var(--text-sub); } .cp-bubble { position: absolute; right: 8px; top: -12px; background: var(--text); color: var(--surface); border-radius: 12px; padding: 3px 10px; font-weight: 800; font-size: .85rem; box-shadow: var(--shadow); animation: cp-pop .25s ease-out; z-index: 2; max-width: 80%; } .cp-mid { display: grid; grid-template-columns: minmax(0, 340px) minmax(0, 1fr); gap: 12px; align-items: start; } .cp-table { background: var(--felt); color: #fff; border-radius: var(--radius-lg); padding: 12px; display: flex; flex-direction: column; gap: 10px; align-items: center; min-height: 230px; } .cp-table .small { color: #fff; opacity: .92; } .cp-chain { display: flex; flex-wrap: wrap; gap: 4px 6px; align-items: center; justify-content: center; font-size: .85rem; font-weight: 700; } .cp-chain .arrow { opacity: .8; } .cp-chain .said { background: rgba(255,255,255,.18); border-radius: 8px; padding: 0 6px; } .cp-flight { display: flex; align-items: center; gap: 12px; } .cp-speech { background: #fff; color: #1c1e21; border-radius: 14px; padding: 8px 12px; font-weight: 900; font-size: 1.05rem; position: relative; max-width: 170px; } .cp-speech::before { content: ''; position: absolute; left: -8px; top: 50%; margin-top: -7px; border: 7px solid transparent; border-right-color: #fff; border-left: 0; } .cp-speech .who { display: block; font-size: .78rem; font-weight: 700; color: #4a4f57; } .cp-seenrow { font-size: .85rem; display: flex; gap: 4px; flex-wrap: wrap; justify-content: center; align-items: center; } .cp-seenrow .s { background: rgba(0,0,0,.25); border-radius: 999px; padding: 0 8px; font-weight: 700; } .cp-reveal { display: flex; align-items: center; gap: 10px; background: rgba(0,0,0,.22); border-radius: 12px; padding: 8px 10px; width: 100%; } .cp-reveal .verdict { font-weight: 900; font-size: 1.05rem; } .cp-reveal .verdict.ok { color: #86efac; } .cp-reveal .verdict.no { color: #fca5a5; } .cp-card { --w: 64px; width: var(--w); height: calc(var(--w) * 1.32); border-radius: 10px; border: 2px solid; display: inline-flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px; box-shadow: 0 3px 0 rgba(0,0,0,.28); flex: none; user-select: none; position: relative; } .cp-card.sm { --w: 40px; border-radius: 7px; } .cp-card.lg { --w: 92px; border-radius: 12px; } .cp-card-art { width: 82%; height: auto; } .cp-card-name { font-weight: 900; font-size: calc(var(--w) * .2); line-height: 1.1; white-space: nowrap; } .cp-card.sm .cp-card-name { display: none; } .cp-card.back { background: repeating-radial-gradient(circle at 30% 30%, #7c3aed 0 5px, #6d28d9 5px 10px); border-color: #3b0764; color: #fff; } .cp-back-q { font-weight: 900; font-size: calc(var(--w) * .5); text-shadow: 0 2px 0 rgba(0,0,0,.35); } .cp-card.slide { animation: cp-slide .45s ease-out; } .cp-card.flip { animation: cp-flip .55s ease-out; } .cp-card.mine { outline: 3px dashed #fde047; outline-offset: 3px; } @keyframes cp-slide { 0% { transform: translateX(-40px) rotate(-12deg); opacity: .2; } 100% { transform: none; opacity: 1; } } @keyframes cp-flip { 0% { transform: rotateY(90deg) scale(.9); } 100% { transform: none; } } @keyframes cp-pop { 0% { transform: scale(.6); opacity: 0; } 100% { transform: none; opacity: 1; } } .cp-panel { container-type: inline-size; background: var(--surface); border-radius: var(--radius-lg); box-shadow: var(--shadow); padding: 12px; display: flex; flex-direction: column; gap: 10px; min-width: 0; } .cp-step { display: flex; flex-direction: column; gap: 6px; } .cp-step > strong { font-size: .95rem; } .cp-step.dim { opacity: .55; } .cp-hand { display: flex; flex-wrap: wrap; gap: 8px; } .cp-pick { position: relative; background: none; border: 0; padding: 3px; border-radius: 13px; cursor: pointer; color: inherit; } .cp-pick:disabled { cursor: default; } .cp-pick[aria-pressed='true'] { box-shadow: 0 0 0 4px var(--primary); } .cp-pick[aria-pressed='true'] .cp-card { transform: translateY(-4px); } .cp-pick:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } .cp-count { position: absolute; right: -3px; top: -5px; min-width: 24px; height: 24px; border-radius: 12px; background: var(--text); color: var(--surface); font-weight: 900; font-size: .85rem; display: flex; align-items: center; justify-content: center; padding: 0 5px; box-shadow: 0 1px 3px rgba(0,0,0,.3); font-variant-numeric: tabular-nums; } .cp-targets { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; } .cp-target { min-height: 52px; border-radius: 12px; border: 2px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; display: flex; align-items: center; gap: 7px; padding: 4px 9px; text-align: left; font-weight: 700; } .cp-target .t-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; } .cp-target .t-sub { font-size: .75rem; color: var(--text-sub); display: block; font-weight: 600; white-space: normal; line-height: 1.25; } .cp-target[aria-pressed='true'] { border-color: var(--primary); background: var(--primary-soft); } .cp-target:disabled { opacity: .45; cursor: not-allowed; } .cp-target:focus-visible, .cp-claim:focus-visible, .cp-say button:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } .cp-claims { display: grid; grid-template-columns: repeat(4, minmax(64px, 1fr)); gap: 8px; } .cp-claim { min-height: 72px; border-radius: 12px; border: 2px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; font-weight: 800; font-size: .88rem; padding: 4px 2px; } .cp-claim[aria-pressed='true'] { border-color: var(--primary); background: var(--primary-soft); box-shadow: 0 0 0 2px var(--primary); } @container (min-width: 600px) { .cp-claims { grid-template-columns: repeat(8, minmax(64px, 1fr)); } .cp-hand .cp-card { --w: 58px; } } .cp-claim .truth { font-size: .7rem; color: var(--success); font-weight: 800; } .cp-big { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } .cp-big .btn { min-height: 64px; font-size: 1.2rem; } .cp-true { background: var(--success); color: #fff; border: 0; } .cp-false { background: var(--danger); color: #fff; border: 0; } .cp-true:hover:not(:disabled), .cp-false:hover:not(:disabled) { filter: brightness(1.08); } .cp-peek { min-height: 56px; } .cp-why { font-size: .85rem; color: var(--text-sub); } .cp-me { background: var(--surface); border-radius: var(--radius-lg); box-shadow: var(--shadow); padding: 10px 12px; border: 3px solid var(--primary); display: flex; flex-direction: column; gap: 8px; } .cp-me.turn { border-color: var(--focus); } .cp-me-row { display: flex; gap: 14px; flex-wrap: wrap; align-items: flex-start; } .cp-me-row > div { display: flex; flex-direction: column; gap: 6px; min-width: 0; } .cp-help { font-size: .85rem; color: var(--text-sub); } .cp-say { display: flex; flex-wrap: wrap; gap: 6px; } .cp-say button { min-height: 44px; border-radius: 999px; border: 2px solid var(--border); background: var(--surface); color: var(--text); font-weight: 700; padding: 0 12px; cursor: pointer; } .cp-say button:disabled { opacity: .45; cursor: not-allowed; } .cp-log { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; font-size: .9rem; } .cp-log li { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } @media (max-width: 719px) { .cp-board { max-height: none; overflow: visible; } .cp-seats { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .cp-seat { padding: 7px 8px; } .cp-mid { grid-template-columns: minmax(0, 1fr); } .cp-table { min-height: 0; } .cp-card { --w: 54px; } .cp-card.lg { --w: 76px; } .cp-targets { grid-template-columns: repeat(2, minmax(0, 1fr)); } .cp-claims { gap: 6px; } .cp-claim { min-height: 70px; font-size: .82rem; } } @media (prefers-reduced-motion: reduce) { .cp-card.slide, .cp-card.flip, .cp-bubble { animation: none; } .cp-pick[aria-pressed='true'] .cp-card { transform: none; } } `; // ---------------------------------------------------------------- pieces function FrontChips({ front, loseCount, size = 22 }: { front: FrontView; loseCount: number; size?: number }) { if (front.total === 0) return 앞마당 비어 있음; return ( <> {front.kinds.map((k) => { const lv = front.level[k]; return ( {front.counts[k]} {lv === 'danger' && !} ); })} ); } function Seat({ id, v, me, players, deadline, bubble }: { id: string; v: CockroachView; me: string | null; players: Record; deadline: number | null; bubble: string | null }) { const u = userOf(players, id); const r = v.round; const active = waitingOn(v) === id; const loser = v.final?.loser === id; const front = v.fronts[id]!; const role = v.phase === 'finished' ? null : r.holder === id ? (r.peeked ? '넘기는 중' : '받는 중') : r.holder === null && r.starter === id ? '건네는 중' : null; const label = `${u.nickname}님${id === me ? '(나)' : ''}: 손패 ${v.handCounts[id]}장, ${frontText(front, v.loseCount)}${role ? `, ${role}` : ''}${r.seen.includes(id) && r.holder !== null ? ', 이 카드를 봤어요' : ''}${v.away[id] ? ', 자리 비움' : ''}`; return (
{bubble && ( {bubble} )}
{u.nickname} {id === me && (나)} {active && }
{role && {role}} {r.holder !== null && r.seen.includes(id) && 👁 봤어요} {v.away[id] && 자리 비움} {v.final && {loser ? '패배' : '승리'}}
손패 {v.handCounts[id]}장 · 앞마당 {front.total}장
); } function Chain({ claims, players }: { claims: ClaimEntry[]; players: Record }) { if (claims.length === 0) return null; return (
`${nick(players, c.from)}님이 ${nick(players, c.to)}님에게 ${kj(c.claim, '이라고', '라고')} 함`).join(', ')}`}> {nick(players, claims[0]!.from)} {claims.map((c, i) => ( → 「{KIND_KO[c.claim]}」 {nick(players, c.to)} ))}
); } function RevealLine({ rv, players, me, fresh }: { rv: LastReveal; players: Record; me: string | null; fresh: boolean }) { const callWord = rv.call === 'true' ? '진짜!' : '거짓말!'; const truth = rv.kind === rv.claim; const caller = rv.caller === me ? '내가' : `${nick(players, rv.caller)}님이`; const taker = rv.takenBy === me ? '내' : `${nick(players, rv.takenBy)}님`; return (
"{KIND_KO[rv.claim]}"{hasBatchim(KIND_KO[rv.claim]) ? "이라는" : "라는"} 말에 {caller} "{callWord}"{rv.auto ? '(시간 초과)' : ''} → 실제로는 {KIND_KO[rv.kind]}({truth ? '진짜' : '거짓'})
{rv.correct ? '맞혔어요!' : '틀렸어요!'} {kj(rv.kind, '은', '는')} {taker} 앞마당으로
); } function Table({ v, me, players, fresh }: { v: CockroachView; me: string | null; players: Record; fresh: { slide: boolean; flip: boolean } }) { const r = v.round; if (v.phase === 'finished') { const f = v.final!; return (
게임이 끝났어요 {f.losingKind && } {nick(players, f.loser)}님 패배 · 나머지 모두 승리 {v.lastReveal && }
); } return (
{r.holder !== null && r.claim ? ( <> {v.options.claimHistory && }
{v.card && v.card.known ? : } {r.lastPasser === me ? '내가' : `${nick(players, r.lastPasser!)}님이`} {r.holder === me ? '나에게' : `${nick(players, r.holder)}님에게`} {claimLine(r.claim)}
{v.card?.known && 점선 카드는 내가 아는 카드예요{v.card.kind === r.claim ? ' (지금 주장은 진짜)' : ' (지금 주장은 거짓)'}}
nick(players, p)).join(', ')}`}> 👁 본 사람 {r.seen.map((p) => ( {p === me ? '나' : nick(players, p)} ))} {r.passes > 0 && · {r.passes}번 넘어왔어요}
) : ( <> {r.starter === me ? '내가 카드를 건넬 차례예요' : `${nick(players, r.starter)}님의 카드를 기다리는 중`} )} {v.lastReveal && (r.holder === null || v.lastReveal.round === r.no - 1) && }
); } function Step({ title, dim, children, label }: { title: string; dim?: boolean; children: ReactNode; label: string }) { return (
{title} {children}
); } function TargetButtons({ v, players, allowed, value, onPick, groupRef }: { v: CockroachView; players: Record; allowed: string[]; value: string | null; onPick: (p: string) => void; groupRef?: React.Ref }) { const me = v.me; const others = me ? v.order.slice(v.order.indexOf(me) + 1).concat(v.order.slice(0, v.order.indexOf(me))) : v.order; return (
{others.map((p) => { const ok = allowed.includes(p); const seen = v.round.seen.includes(p); return ( ); })}
); } function ClaimButtons({ value, onPick, truth, groupRef }: { value: Kind | null; onPick: (k: Kind) => void; truth: Kind | null; groupRef?: React.Ref }) { return (
{KINDS.map((k) => ( ))}
); } /** Offer (pick card → target → claim) or pass (target → claim) flow. */ function GiveFlow({ v, players, act, pending }: { v: CockroachView; players: Record; act: (a: unknown) => void; pending: boolean }) { const my = v.my!; const offering = my.canOffer; const [card, setCard] = useState(null); const [to, setTo] = useState(null); const [claim, setClaim] = useState(null); const targetsRef = useRef(null); const claimsRef = useRef(null); const confirmRef = useRef(null); const resetKey = `${v.round.no}|${v.round.holder}|${v.round.peeked}`; useEffect(() => { setCard(null); setTo(null); setClaim(null); }, [resetKey]); const focusFirst = (el: HTMLElement | null) => setTimeout(() => el?.querySelector('button:not(:disabled)')?.focus({ preventScroll: true }), 0); const known = v.card && v.card.known ? v.card.kind : null; const cardKind = offering ? card : known; const allowed = offering ? my.offerTargets : my.passTargets; const ready = !!cardKind && !!to && !!claim; const send = () => { if (!ready || pending) return; if (offering) { const id = v.myHand!.find((g) => g.kind === card)!.ids[0]!; act({ type: 'offer', cardId: id, to, claim }); } else act({ type: 'pass', to, claim }); }; const n = offering ? 3 : 2; return (
{ if (e.key === 'Escape') { setCard(null); setTo(null); setClaim(null); } }} > {offering ? '카드를 건네 주세요' : '본 카드를 다른 사람에게 넘겨 주세요'} {!offering && known && (
몰래 본 카드는 {KIND_KO[known]}{hasBatchim(KIND_KO[known]) ? '이에요' : '예요'}. 아직 안 본 사람에게만 넘길 수 있어요.
)} {offering && (
{v.myHand!.map((g) => ( ))}
)} { setTo(p); if (!claim) focusFirst(claimsRef.current); }} /> { setClaim(k); setTimeout(() => confirmRef.current?.focus({ preventScroll: true }), 0); }} /> {cardKind && claim && {cardKind === claim ? '진짜를 말하는 거예요.' : `${kj(cardKind, '을', '를')} ${kj(claim, '이라고', '라고')} 속이는 거예요.`}}
); } function RespondPanel({ v, players, act, pending }: { v: CockroachView; players: Record; act: (a: unknown) => void; pending: boolean }) { const my = v.my!; const r = v.round; const firstRef = useRef(null); useEffect(() => { firstRef.current?.focus({ preventScroll: true }); }, [r.no, r.claims.length]); return (
{nick(players, r.lastPasser!)}님이 "{claimLine(r.claim!)}"라고 했어요. 진짜일까요?
{my.peekBlocked ? {my.peekBlocked} : 몰래 보면 판정할 수 없고, 아직 안 본 사람에게 꼭 넘겨야 해요.} 맞히면 {nick(players, r.lastPasser!)}님이, 틀리면 내가 이 카드를 앞마당에 놓아요.
); } function WaitPanel({ v, players, me }: { v: CockroachView; players: Record; me: string | null }) { if (v.phase === 'finished') { const f = v.final!; return (
게임이 끝났어요 {f.summary} 패배: {nick(players, f.loser)}님 · 오른쪽에서 모두의 남은 손패를 볼 수 있어요.
); } const r = v.round; const who = nick(players, waitingOn(v)!); const doing = r.holder === null ? '건넬 카드를 고르는 중이에요' : r.peeked ? '몰래 본 카드를 넘길 사람을 고르는 중이에요' : `"${claimLine(r.claim!)}"를 듣고 고민하는 중이에요`; return (
{who}님이 {doing} {me && v.order.includes(me) && r.holder !== null && r.seen.includes(me) && 나는 이 카드를 봤어요. 다시 받을 수 없어요.} 같은 벌레가 {v.loseCount}장 모이면 져요. 빨간 배지는 한 장만 더 모이면 지는 벌레예요.
); } function MyArea({ v, me, players, deadline, offering }: { v: CockroachView; me: string; players: Record; deadline: number | null; offering: boolean }) { const front = v.fronts[me]!; const active = waitingOn(v) === me; const total = v.handCounts[me] ?? 0; return (
{nick(players, me)} (나) {v.away[me] && 자리 비움} {active && }
내 앞마당 {front.total}장
내 손패 {total}장{offering ? ' (위에서 고르세요)' : ''} {!offering && (
`${KIND_KO[g.kind]} ${g.ids.length}장`).join(', ') || '없음'}`}> {(v.myHand ?? []).map((g) => ( {g.ids.length} ))} {total === 0 && 손패가 없어요. 카드를 건넬 차례가 오면 져요.}
)}
); } // ---------------------------------------------------------------- board function CockroachBoard({ view: v, me, players, act, pending, deadline }: BoardProps) { const amPlayer = !!me && v.order.includes(me); const [announce, setAnnounce] = useState(''); const [bubbles, setBubbles] = useState>({}); const [fresh, setFresh] = useState({ slide: false, flip: false }); const prev = useRef({ claims: v.round.claims.length + v.round.no * 100, reveal: v.lastReveal?.round ?? 0, said: v.said[v.said.length - 1]?.seq ?? 0, waiting: waitingOn(v) }); useEffect(() => { const p = prev.current; const claimsKey = v.round.claims.length + v.round.no * 100; const revealKey = v.lastReveal?.round ?? 0; const lastSaid = v.said[v.said.length - 1]?.seq ?? 0; const w = waitingOn(v); let msg = ''; const slide = claimsKey !== p.claims && v.round.holder !== null; const flip = revealKey !== p.reveal && !!v.lastReveal; if (flip) { const rv = v.lastReveal!; msg = `${nick(players, rv.caller)}님이 ${rv.call === 'true' ? '진짜라고' : '거짓말이라고'} 했고 ${rv.correct ? '맞혔어요' : '틀렸어요'}. ${kj(rv.kind, '은', '는')} ${nick(players, rv.takenBy)}님 앞마당으로 갔어요.`; if (rv.takenBy === me) haptic([40, 30, 40]); } else if (slide && v.round.claim && v.round.lastPasser) { msg = `${nick(players, v.round.lastPasser)}님이 ${nick(players, v.round.holder!)}님에게 "${claimLine(v.round.claim)}"라고 카드를 건넸어요.`; } if (w !== p.waiting && w === me && v.phase !== 'finished') { sounds.myTurn(); haptic(40); msg = `${msg} 내 차례예요.`.trim(); } if (msg) setAnnounce(msg); if (lastSaid > p.said) { const add: Record = {}; for (const s of v.said) if (s.seq > p.said) add[s.player] = PHRASE_KO[s.phrase]; setBubbles((b) => ({ ...b, ...add })); setTimeout(() => setBubbles((b) => Object.fromEntries(Object.entries(b).filter(([k]) => !(k in add)))), 3500); } prev.current = { claims: claimsKey, reveal: revealKey, said: lastSaid, waiting: w }; if (slide || flip) { setFresh({ slide, flip }); const t = setTimeout(() => setFresh({ slide: false, flip: false }), 700); return () => clearTimeout(t); } return undefined; }, [v.seq]); // eslint-disable-line react-hooks/exhaustive-deps const offering = amPlayer && !!v.my?.canOffer; const passing = amPlayer && !!v.my?.canPass; const responding = amPlayer && v.round.holder === me && !v.round.peeked && v.phase === 'respond'; const seats = amPlayer ? v.order.slice(v.order.indexOf(me!) + 1).concat(v.order.slice(0, v.order.indexOf(me!))) : v.order; return (
{seats.map((p) => ( ))}
{offering || passing ? ( ) : responding ? ( ) : ( )} {amPlayer && } {amPlayer && bubbles[me!] && ( {bubbles[me!]} )}

{announce}

); } // ---------------------------------------------------------------- side function CockroachSide({ view: v, me, players, act, pending, deadline }: BoardProps) { const running = v.phase !== 'finished'; const w = waitingOn(v); const r = v.round; return ( <>
{running ? `${r.no}라운드 · ${nick(players, w!)}님 차례` : '게임 끝'} {running && }
{optionsSummary(v.options, v.order.length)}
    {v.order.map((p) => (
  1. {nick(players, p)} {w === p ? ' · 차례' : ''} {v.final ? (v.final.loser === p ? ' · 패배' : ' · 승리') : ''} 손 {v.handCounts[p]} · 앞 {v.fronts[p]!.total}
  2. ))}
{v.removedCount > 0 && running && 2명 게임이라 카드 {v.removedCount}장을 보지 않고 뺐어요.}
{v.final && } {running && v.options.claimHistory && r.claims.length > 0 && (
이번 라운드 주장
    {r.claims.map((c, i) => (
  1. {nick(players, c.from)} → {nick(players, c.to)}: "{claimLine(c.claim)}"{c.auto ? ' (시간 초과)' : ''}
  2. ))}
)} {running && v.options.quickChat && me && v.order.includes(me) && (
빠른 말
{PHRASES.map((ph) => ( ))}
한 라운드에 3번까지 말할 수 있어요.
)}
도움말
  • 같은 벌레가 앞마당에 {v.loseCount}장 모이면 져요.
  • 노란 배지는 조심, 빨간 배지(!)는 한 장만 더 오면 지는 벌레예요.
  • 받은 카드는 "진짜!"·"거짓말!"로 판정하거나, 몰래 보고 아직 안 본 사람에게 넘길 수 있어요.
  • 맞히면 건넨 사람이, 틀리면 받은 사람이 카드를 가져가고 다음 라운드를 시작해요.
); } function FinalCard({ v, players }: { v: CockroachView; players: Record }) { const f = v.final!; return (
결과 {f.summary} 자세한 순위(같은 벌레가 적은 순 → 앞마당이 적은 순)
    {f.placings.map((g, i) => (
  1. {i + 1}위 {g.map((p) => `${nick(players, p)}${p === f.loser ? ' (패배)' : ''}`).join(', ')}
  2. ))}
{v.revealed && ( <> 남은 손패 공개
    {v.order.map((p) => (
  • {nick(players, p)}: {v.revealed!.hands[p]!.length === 0 ? '없음' : summarizeIds(v.revealed!.hands[p]!)}
  • ))} {v.revealed.removed.length > 0 &&
  • 뺀 카드: {summarizeIds(v.revealed.removed)}
  • }
)}
); } function summarizeIds(ids: string[]): string { const counts = new Map(); for (const id of ids) { const k = id.slice(0, id.lastIndexOf('-')) as Kind; counts.set(k, (counts.get(k) ?? 0) + 1); } return KINDS.filter((k) => counts.has(k)) .map((k) => `${KIND_KO[k]} ${counts.get(k)}`) .join(' · '); } // ---------------------------------------------------------------- status function isMyTurn(v: CockroachView, me: string | null): boolean { return !!me && v.order.includes(me) && waitingOn(v) === me; } function statusText(v: CockroachView, me: string | null, players: Record): string { if (v.phase === 'finished') return v.final?.summary ?? '게임이 끝났어요'; const r = v.round; if (isMyTurn(v, me)) { if (r.holder === null) return '내 차례예요. 건넬 카드, 받을 사람, 할 말을 골라 주세요'; if (r.peeked) return '내 차례예요. 본 카드를 넘길 사람과 할 말을 골라 주세요'; return `내 차례예요. ${nick(players, r.lastPasser!)}님이 "${claimLine(r.claim!)}"라고 했어요`; } const who = nick(players, waitingOn(v)!); if (r.holder === null) return `${who}님이 카드를 건네는 중이에요`; return `${who}님이 ${r.peeked ? '카드를 넘기는' : '판단하는'} 중이에요`; } // ---------------------------------------------------------------- options 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}
); } function Options({ value: o, onChange }: OptionsProps) { const [more, setMore] = useState(false); const set = (patch: Partial) => onChange({ ...o, ...patch }); return (
set({ loseCount })} /> set({ turnSeconds })} /> set({ claimHistory })} /> set({ quickChat })} /> {more && ( <> set({ eightKindsRule })} /> set({ twoPlayerPass })} /> )}
); } function optionsSummary(o: CockroachOptions, players?: number): string { const lose = o.loseCount === 'auto' ? (players ? `${players === 2 ? 5 : 4}장` : '자동(2명 5장, 그 외 4장)') : `${o.loseCount}장`; return [ `같은 벌레 ${lose}이면 패배`, `제한 ${o.turnSeconds}초`, ...(o.claimHistory ? [] : ['주장 기록 숨김']), ...(o.quickChat ? [] : ['빠른 말 끔']), ...(o.eightKindsRule ? ['8종 모으면 패배'] : []), ...(o.twoPlayerPass ? ['2명 되돌려 주기'] : []), ].join(' · '); } function Rules({ options }: { options?: CockroachOptions }) { const o = options; const lose = !o || o.loseCount === 'auto' ? '4장(2명이면 5장)' : `${o.loseCount}장`; return (

벌레 카드를 뒤집어 건네며 "이건 쥐야!"처럼 말하고, 받은 사람은 그 말이 진짜인지 거짓말인지 맞히는 눈치 게임이에요. 2~6명이 할 수 있어요. 같은 벌레가 내 앞마당에 {lose} 모이면 지고, 나머지는 모두 이겨요.

KIND_KO[k]).join(', ')}, 종류마다 8장`}> {KINDS.map((k) => ( ))}

준비: 벌레 8종 × 8장 = 64장을 섞어 모두 나눠요(2명이면 10장을 보지 않고 빼고 27장씩). 첫 사람은 무작위로 정해요.

건네기: 차례인 사람이 손패 1장을 골라 다른 사람에게 뒷면으로 건네며 벌레 이름 하나를 말해요. 진짜를 말해도, 거짓말을 해도 돼요.

받은 사람은 둘 중 하나를 골라요.

  • 진짜! / 거짓말!: 카드를 공개해요. 맞히면 건넨 사람이, 틀리면 내가 그 카드를 앞마당에 앞면으로 놓아요.
  • 몰래 보고 넘기기: 카드를 혼자 보고, 아직 이 카드를 보지 않은 사람에게 다시 말하며 넘겨요. 처음 건넨 사람과 이미 본 사람에게는 넘길 수 없어요. 모두 봤다면 꼭 판정해야 해요. {o?.twoPlayerPass ? ' 2명일 때는 한 번만 되돌려 줄 수 있어요.' : ' 2명일 때는 넘기기 없이 항상 판정해요.'}

카드를 앞마당에 놓은 사람이 다음 라운드를 시작해요. 그때 손에 카드가 한 장도 없으면 바로 져요.

{o?.eightKindsRule &&

하우스 룰: 앞마당에 8종을 모두 한 장 이상 모아도 져요.

}

시간 안에 고르지 않으면 자동으로 진행돼요(건네기: 무작위 카드를 다음 사람에게, 판정: 무작위, 넘기기: 아직 안 본 다음 사람에게 실제 벌레 이름으로). 세 번 연속 시간을 넘기면 제한 시간이 5초로 줄어요. 게임을 나가면 그 사람이 진 것으로 끝나요.

); } const ui: GameUI = { Board: CockroachBoard, Side: CockroachSide, statusText, isMyTurn, Options, optionsSummary: (o) => optionsSummary(o), Rules, }; export default ui;