Files
joke-app/apps/web/src/games/cockroach-poker/index.tsx
EJClaw 3e414e62ca M7: 바퀴벌레 포커 규칙·화면·테스트
- 규칙 모듈(packages/games/src/cockroach-poker): 64장(8종×8) 셔플·좌석순 분배, 2인 10장 비공개 제거,
  offer/call/peek/pass 흐름, seen 규칙, 2인 판정 전용(twoPlayerPass 하우스 룰은 1회 되돌려 주기),
  같은 종류 loseCount장(자동: 2인 5, 3~6인 4)·손패 0장 시작자·eightKindsRule 패배,
  시간 초과 자동 행동(§8.5)과 3연속 시간 초과 시 자리 비움(5초), 나가면 그 사람 패배,
  빠른 말(say, 라운드당 3번), view는 남의 손패·미확인자에게 건네는 카드·제거 카드를 숨김
- 테스트: §10 시나리오 전부, randomPlayout 200판 + 빠른 루프 800판(카드 보존·유출·onTimeout 합법·재생 결정성)
- 화면(apps/web/src/games/cockroach-poker): 자체 SVG 벌레 8종, 앞마당 종류별 배지(경고/위험),
  건네기 3단계(카드→사람→주장), 판정 시트(진짜!/거짓말!/몰래 보고 넘기기), 넘긴 순서·본 사람 표시
- 등록: GAMES, CATALOG, 웹 registry, GAME_ICON / E2E 플랜(PC 방장 + 휴대폰 2명, 끝까지 진행)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:46:30 +09:00

1060 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 바퀴벌레 포커 화면 (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<Kind, string> = {
cockroach: '바퀴벌레',
bat: '박쥐',
fly: '파리',
toad: '두꺼비',
rat: '쥐',
scorpion: '전갈',
spider: '거미',
stinkbug: '노린재',
};
const PHRASES: Phrase[] = ['really', 'doubt', 'hmm', 'trust'];
const PHRASE_KO: Record<Phrase, string> = { really: '정말이야!', doubt: '의심스러운데?', hmm: '음… 고민되네', trust: '믿어 볼게' };
const nick = (players: Record<string, PublicUser>, id: string) => players[id]?.nickname ?? '알 수 없음';
const userOf = (players: Record<string, PublicUser>, id: string): PublicUser =>
players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
const 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<Kind, { bg: string; ink: string }> = {
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 (
<g>
<circle cx={x1} cy={y} r={r} fill="#fff" />
<circle cx={x2} cy={y} r={r} fill="#fff" />
<circle cx={x1 + 0.6} cy={y + 0.6} r={r * 0.5} fill="#15161a" />
<circle cx={x2 + 0.6} cy={y + 0.6} r={r * 0.5} fill="#15161a" />
</g>
);
}
/** 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 (
<g stroke={c} strokeWidth={2.4} strokeLinecap="round" fill="none">
<path d="M28 14 C22 6 16 4 10 6 M36 14 C42 6 48 4 54 6" />
<path d="M20 30 L10 26 M19 38 L8 40 M21 46 L12 54 M44 30 L54 26 M45 38 L56 40 M43 46 L52 54" />
<ellipse cx={32} cy={39} rx={13} ry={17} fill={c} stroke="none" />
<path d="M32 24 L32 55" stroke="#f4e4d0" strokeWidth={1.6} />
<circle cx={32} cy={19} r={8} fill={c} stroke="none" />
<Eyes x1={28.5} x2={35.5} y={18} r={2.8} />
</g>
);
case 'bat':
return (
<g>
<path d="M32 28 C24 16 12 16 3 24 C9 27 11 33 11 38 C15 34 21 34 23 40 C26 36 29 36 32 40 C35 36 38 36 41 40 C43 34 49 34 53 38 C53 33 55 27 61 24 C52 16 40 16 32 28 Z" fill={c} />
<path d="M24 22 L22 12 L29 19 Z M40 22 L42 12 L35 19 Z" fill={c} />
<ellipse cx={32} cy={30} rx={9} ry={11} fill={c} />
<Eyes x1={28.5} x2={35.5} y={27} r={3} />
<path d="M29 34 L30 37 L31 34 M33 34 L34 37 L35 34" stroke="#fff" strokeWidth={1.2} fill="none" />
</g>
);
case 'fly':
return (
<g>
<ellipse cx={20} cy={30} rx={13} ry={8} transform="rotate(-30 20 30)" fill="#ffffff" fillOpacity={0.85} stroke={c} strokeWidth={1.8} />
<ellipse cx={44} cy={30} rx={13} ry={8} transform="rotate(30 44 30)" fill="#ffffff" fillOpacity={0.85} stroke={c} strokeWidth={1.8} />
<ellipse cx={32} cy={42} rx={9} ry={14} fill={c} />
<path d="M24 40 H40 M24 46 H40" stroke="#dce8f7" strokeWidth={1.6} />
<circle cx={32} cy={24} r={9} fill={c} />
<circle cx={27} cy={23} r={4.6} fill="#c2410c" />
<circle cx={37} cy={23} r={4.6} fill="#c2410c" />
<circle cx={28} cy={22} r={1.4} fill="#fff" />
<circle cx={38} cy={22} r={1.4} fill="#fff" />
</g>
);
case 'toad':
return (
<g>
<ellipse cx={32} cy={42} rx={23} ry={15} fill={c} />
<circle cx={20} cy={27} r={8} fill={c} />
<circle cx={44} cy={27} r={8} fill={c} />
<Eyes x1={20} x2={44} y={26} r={4.6} />
<path d="M20 44 Q32 52 44 44" stroke="#ddefd6" strokeWidth={2.4} fill="none" strokeLinecap="round" />
<circle cx={14} cy={42} r={2} fill="#ddefd6" />
<circle cx={50} cy={40} r={2.4} fill="#ddefd6" />
<circle cx={46} cy={50} r={1.6} fill="#ddefd6" />
</g>
);
case 'rat':
return (
<g>
<path d="M44 52 C56 54 60 44 54 38" stroke={c} strokeWidth={2.6} fill="none" strokeLinecap="round" />
<circle cx={17} cy={20} r={9} fill={c} />
<circle cx={47} cy={20} r={9} fill={c} />
<circle cx={17} cy={20} r={5} fill="#f2a7b5" />
<circle cx={47} cy={20} r={5} fill="#f2a7b5" />
<ellipse cx={32} cy={36} rx={16} ry={17} fill={c} />
<Eyes x1={26} x2={38} y={32} r={3.2} />
<circle cx={32} cy={42} r={2.8} fill="#f2a7b5" />
<path d="M28 43 L16 40 M28 45 L16 47 M36 43 L48 40 M36 45 L48 47" stroke="#fff" strokeWidth={1.1} />
</g>
);
case 'scorpion':
return (
<g fill={c}>
<path d="M32 34 C30 18 40 8 50 10 C56 12 56 20 50 20" stroke={c} strokeWidth={4.5} fill="none" strokeLinecap="round" />
<path d="M50 20 L55 14 L56 23 Z" />
<path d="M22 40 L13 32 M42 40 L51 32" stroke={c} strokeWidth={3} strokeLinecap="round" />
<path d="M5 27 C5 21 12 20 15 25 L11 27 L15 30 C12 34 5 33 5 27 Z" />
<path d="M59 27 C59 21 52 20 49 25 L53 27 L49 30 C52 34 59 33 59 27 Z" />
<path d="M23 50 L15 56 M41 50 L49 56 M24 45 L14 47 M40 45 L50 47" stroke={c} strokeWidth={2.2} strokeLinecap="round" />
<ellipse cx={32} cy={44} rx={11} ry={12} />
<Eyes x1={28} x2={36} y={40} r={2.8} />
</g>
);
case 'spider':
return (
<g stroke={c} strokeWidth={2.4} fill="none" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 30 L12 22 L6 30 M21 35 L9 33 L4 41 M22 40 L12 44 L8 54 M25 44 L18 52 L16 60" />
<path d="M42 30 L52 22 L58 30 M43 35 L55 33 L60 41 M42 40 L52 44 L56 54 M39 44 L46 52 L48 60" />
<path d="M32 4 L32 22" strokeWidth={1.4} strokeDasharray="2 2" />
<circle cx={32} cy={37} r={13} fill={c} stroke="none" />
<Eyes x1={27.5} x2={36.5} y={34} r={3.6} />
<path d="M28 42 Q32 45 36 42" stroke="#fff" strokeWidth={1.4} />
</g>
);
case 'stinkbug':
return (
<g>
<path d="M28 14 C24 8 20 6 15 7 M36 14 C40 8 44 6 49 7" stroke={c} strokeWidth={2.2} fill="none" strokeLinecap="round" />
<path d="M18 30 L9 28 M18 40 L8 44 M46 30 L55 28 M46 40 L56 44" stroke={c} strokeWidth={2.4} strokeLinecap="round" />
<path d="M32 18 L50 25 L47 44 L32 57 L17 44 L14 25 Z" fill={c} />
<path d="M32 26 L42 30 L32 46 L22 30 Z" fill="#d5efec" fillOpacity={0.55} />
<circle cx={32} cy={18} r={6.5} fill={c} />
<Eyes x1={29} x2={35} y={17} r={2.5} />
</g>
);
}
}
/** 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 (
<svg className="cp-ico" viewBox="-4 -4 72 72" width={size} height={size} aria-hidden focusable="false">
<circle cx={32} cy={32} r={36} fill={KIND_COLOR[kind].bg} />
<CritterArt kind={kind} />
</svg>
);
}
/** 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 (
<span className={`cp-card ${size}${fx ? ` ${fx}` : ''}`} style={{ background: col.bg, color: col.ink, borderColor: col.ink }} aria-hidden>
<svg viewBox="0 0 64 64" className="cp-card-art" focusable="false">
<CritterArt kind={kind} />
</svg>
<span className="cp-card-name">{KIND_KO[kind]}</span>
</span>
);
}
function BackCard({ size = 'md', fx }: { size?: 'sm' | 'md' | 'lg'; fx?: string }) {
return (
<span className={`cp-card back ${size}${fx ? ` ${fx}` : ''}`} aria-hidden>
<span className="cp-back-q">?</span>
</span>
);
}
// ---------------------------------------------------------------- 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 <span className="cp-empty">앞마당 비어 있음</span>;
return (
<>
{front.kinds.map((k) => {
const lv = front.level[k];
return (
<span key={k} className={`cp-chip ${lv}`} title={`${KIND_KO[k]} ${front.counts[k]}장${lv === 'danger' ? ` · ${loseCount}장이 되면 져요` : ''}`} style={lv === 'ok' ? { background: KIND_COLOR[k].bg, color: KIND_COLOR[k].ink } : undefined}>
<CritterIcon kind={k} size={size} />
{front.counts[k]}
{lv === 'danger' && <span className="cp-mark">!</span>}
</span>
);
})}
</>
);
}
function Seat({ id, v, me, players, deadline, bubble }: { id: string; v: CockroachView; me: string | null; players: Record<string, PublicUser>; 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 (
<section className={`cp-seat${active ? ' turn' : ''}${loser ? ' loser' : ''}`} aria-label={label}>
{bubble && (
<span className="cp-bubble" aria-hidden>
{bubble}
</span>
)}
<div className="cp-head">
<Avatar user={u} size={26} />
<span className="cp-name">
{u.nickname}
{id === me && <span className="muted"> (나)</span>}
</span>
{active && <TurnTimer deadline={deadline} totalMs={v.turnMs} mine={id === me} size={30} />}
</div>
<div className="cp-badges" aria-hidden>
{role && <span className="cp-badge turn">{role}</span>}
{r.holder !== null && r.seen.includes(id) && <span className="cp-badge seen">👁 봤어요</span>}
{v.away[id] && <span className="cp-badge">자리 비움</span>}
{v.final && <span className={`cp-badge ${loser ? 'lose' : 'win'}`}>{loser ? '패배' : '승리'}</span>}
</div>
<div className="cp-meta" aria-hidden>
<span>손패 {v.handCounts[id]}장</span>
<span>· 앞마당 {front.total}장</span>
</div>
<div className="cp-front" aria-hidden>
<FrontChips front={front} loseCount={v.loseCount} />
</div>
</section>
);
}
function Chain({ claims, players }: { claims: ClaimEntry[]; players: Record<string, PublicUser> }) {
if (claims.length === 0) return null;
return (
<div className="cp-chain" aria-label={`건넨 순서: ${claims.map((c) => `${nick(players, c.from)}님이 ${nick(players, c.to)}님에게 ${kj(c.claim, '이라고', '라고')} 함`).join(', ')}`}>
<span>{nick(players, claims[0]!.from)}</span>
{claims.map((c, i) => (
<span key={i} style={{ display: 'contents' }}>
<span className="arrow" aria-hidden>
→
</span>
<span className="said">「{KIND_KO[c.claim]}」</span>
<span>{nick(players, c.to)}</span>
</span>
))}
</div>
);
}
function RevealLine({ rv, players, me, fresh }: { rv: LastReveal; players: Record<string, PublicUser>; 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 (
<div className="cp-reveal" role="group" aria-label="지난 판정">
<FaceCard kind={rv.kind} size="sm" fx={fresh ? 'flip' : undefined} />
<span className="small" style={{ lineHeight: 1.45 }}>
"{KIND_KO[rv.claim]}"{hasBatchim(KIND_KO[rv.claim]) ? "이라는" : "라는"} 말에 {caller} "{callWord}"{rv.auto ? '(시간 초과)' : ''} → 실제로는 {KIND_KO[rv.kind]}({truth ? '진짜' : '거짓'})
<br />
<span className={`verdict ${rv.correct ? 'ok' : 'no'}`}>{rv.correct ? '맞혔어요!' : '틀렸어요!'}</span> {kj(rv.kind, '은', '는')} {taker} 앞마당으로
</span>
</div>
);
}
function Table({ v, me, players, fresh }: { v: CockroachView; me: string | null; players: Record<string, PublicUser>; fresh: { slide: boolean; flip: boolean } }) {
const r = v.round;
if (v.phase === 'finished') {
const f = v.final!;
return (
<div className="cp-table" role="group" aria-label="테이블">
<strong style={{ fontSize: '1.15rem', textAlign: 'center' }}>게임이 끝났어요</strong>
{f.losingKind && <FaceCard kind={f.losingKind} size="lg" />}
<span style={{ textAlign: 'center' }}>
{nick(players, f.loser)}님 패배 · 나머지 모두 승리
</span>
{v.lastReveal && <RevealLine rv={v.lastReveal} players={players} me={me} fresh={false} />}
</div>
);
}
return (
<div className="cp-table" role="group" aria-label="테이블">
{r.holder !== null && r.claim ? (
<>
{v.options.claimHistory && <Chain claims={r.claims} players={players} />}
<div className="cp-flight">
{v.card && v.card.known ? <FaceCard kind={v.card.kind} size="lg" fx={`mine${fresh.slide ? ' slide' : ''}`} /> : <BackCard size="lg" fx={fresh.slide ? 'slide' : undefined} />}
<span className="cp-speech" role="status">
<span className="who">
{r.lastPasser === me ? '내가' : `${nick(players, r.lastPasser!)}님이`} {r.holder === me ? '나에게' : `${nick(players, r.holder)}님에게`}
</span>
{claimLine(r.claim)}
</span>
</div>
{v.card?.known && <span className="small">점선 카드는 내가 아는 카드예요{v.card.kind === r.claim ? ' (지금 주장은 진짜)' : ' (지금 주장은 거짓)'}</span>}
<div className="cp-seenrow" aria-label={`이 카드를 본 사람: ${r.seen.map((p) => nick(players, p)).join(', ')}`}>
<span aria-hidden>👁 본 사람</span>
{r.seen.map((p) => (
<span key={p} className="s" aria-hidden>
{p === me ? '나' : nick(players, p)}
</span>
))}
{r.passes > 0 && <span aria-hidden>· {r.passes}번 넘어왔어요</span>}
</div>
</>
) : (
<>
<BackCard size="lg" />
<span style={{ fontWeight: 800, textAlign: 'center' }}>
{r.starter === me ? '내가 카드를 건넬 차례예요' : `${nick(players, r.starter)}님의 카드를 기다리는 중`}
</span>
</>
)}
{v.lastReveal && (r.holder === null || v.lastReveal.round === r.no - 1) && <RevealLine rv={v.lastReveal} players={players} me={me} fresh={fresh.flip} />}
</div>
);
}
function Step({ title, dim, children, label }: { title: string; dim?: boolean; children: ReactNode; label: string }) {
return (
<div className={`cp-step${dim ? ' dim' : ''}`} role="group" aria-label={label}>
<strong>{title}</strong>
{children}
</div>
);
}
function TargetButtons({ v, players, allowed, value, onPick, groupRef }: { v: CockroachView; players: Record<string, PublicUser>; allowed: string[]; value: string | null; onPick: (p: string) => void; groupRef?: React.Ref<HTMLDivElement> }) {
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 (
<div className="cp-targets" ref={groupRef}>
{others.map((p) => {
const ok = allowed.includes(p);
const seen = v.round.seen.includes(p);
return (
<button
key={p}
type="button"
className="cp-target"
disabled={!ok}
aria-pressed={ok ? value === p : undefined}
aria-label={`${nick(players, p)}님${ok ? '에게' : seen ? ', 이미 카드를 봐서 넘길 수 없어요' : ', 고를 수 없어요'} · 손패 ${v.handCounts[p]}장 · ${frontText(v.fronts[p]!, v.loseCount)}`}
onClick={() => onPick(p)}
>
<Avatar user={userOf(players, p)} size={26} />
<span className="t-name">
{nick(players, p)}
<span className="t-sub">{ok ? `손패 ${v.handCounts[p]}장 · 앞마당 ${v.fronts[p]!.total}장` : seen ? '이미 봤어요' : '고를 수 없어요'}</span>
</span>
</button>
);
})}
</div>
);
}
function ClaimButtons({ value, onPick, truth, groupRef }: { value: Kind | null; onPick: (k: Kind) => void; truth: Kind | null; groupRef?: React.Ref<HTMLDivElement> }) {
return (
<div className="cp-claims" ref={groupRef}>
{KINDS.map((k) => (
<button key={k} type="button" className="cp-claim" aria-pressed={value === k} aria-label={`${kj(k, '이라고', '라고')} 말하기${truth === k ? ' (진짜)' : truth ? ' (거짓말)' : ''}`} onClick={() => onPick(k)}>
<CritterIcon kind={k} size={34} />
{KIND_KO[k]}
{truth === k && <span className="truth">진짜</span>}
</button>
))}
</div>
);
}
/** Offer (pick card → target → claim) or pass (target → claim) flow. */
function GiveFlow({ v, players, act, pending }: { v: CockroachView; players: Record<string, PublicUser>; act: (a: unknown) => void; pending: boolean }) {
const my = v.my!;
const offering = my.canOffer;
const [card, setCard] = useState<Kind | null>(null);
const [to, setTo] = useState<string | null>(null);
const [claim, setClaim] = useState<Kind | null>(null);
const targetsRef = useRef<HTMLDivElement>(null);
const claimsRef = useRef<HTMLDivElement>(null);
const confirmRef = useRef<HTMLButtonElement>(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<HTMLButtonElement>('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 (
<div
className="cp-panel"
onKeyDown={(e) => {
if (e.key === 'Escape') {
setCard(null);
setTo(null);
setClaim(null);
}
}}
>
<strong style={{ fontSize: '1.05rem' }}>{offering ? '카드를 건네 주세요' : '본 카드를 다른 사람에게 넘겨 주세요'}</strong>
{!offering && known && (
<div className="row" style={{ gap: 10, flexWrap: 'nowrap' }}>
<FaceCard kind={known} size="sm" />
<span className="small">
몰래 본 카드는 <strong>{KIND_KO[known]}</strong>{hasBatchim(KIND_KO[known]) ? '이에요' : '예요'}. 아직 안 본 사람에게만 넘길 수 있어요.
</span>
</div>
)}
{offering && (
<Step title={`1/${n} 건넬 카드 고르기`} label="건넬 카드 고르기">
<div className="cp-hand">
{v.myHand!.map((g) => (
<button
key={g.kind}
type="button"
className="cp-pick"
aria-pressed={card === g.kind}
aria-label={`${KIND_KO[g.kind]} ${g.ids.length}장 중 1장 고르기`}
onClick={() => {
setCard(g.kind);
if (!to) focusFirst(targetsRef.current);
}}
>
<FaceCard kind={g.kind} />
<span className="cp-count" aria-hidden>
{g.ids.length}
</span>
</button>
))}
</div>
</Step>
)}
<Step title={`${offering ? 2 : 1}/${n} 받을 사람 고르기`} dim={offering && !card} label="받을 사람 고르기">
<TargetButtons
v={v}
players={players}
allowed={allowed}
value={to}
groupRef={targetsRef}
onPick={(p) => {
setTo(p);
if (!claim) focusFirst(claimsRef.current);
}}
/>
</Step>
<Step title={`${n}/${n} 뭐라고 말할까요?`} dim={!to} label="주장할 벌레 고르기">
<ClaimButtons
value={claim}
truth={cardKind}
groupRef={claimsRef}
onPick={(k) => {
setClaim(k);
setTimeout(() => confirmRef.current?.focus({ preventScroll: true }), 0);
}}
/>
</Step>
<button ref={confirmRef} type="button" className="btn btn-primary" style={{ minHeight: 56 }} disabled={!ready || pending} onClick={send}>
{ready ? `${nick(players, to!)}님에게 "${claimLine(claim!)}" ${offering ? '건네기' : '넘기기'}` : offering ? '카드·사람·벌레를 골라 주세요' : '사람과 벌레를 골라 주세요'}
</button>
{cardKind && claim && <span className="cp-why">{cardKind === claim ? '진짜를 말하는 거예요.' : `${kj(cardKind, '을', '를')} ${kj(claim, '이라고', '라고')} 속이는 거예요.`}</span>}
</div>
);
}
function RespondPanel({ v, players, act, pending }: { v: CockroachView; players: Record<string, PublicUser>; act: (a: unknown) => void; pending: boolean }) {
const my = v.my!;
const r = v.round;
const firstRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
firstRef.current?.focus({ preventScroll: true });
}, [r.no, r.claims.length]);
return (
<div className="cp-panel">
<strong style={{ fontSize: '1.05rem' }}>
{nick(players, r.lastPasser!)}님이 "{claimLine(r.claim!)}"라고 했어요. 진짜일까요?
</strong>
<div className="cp-big">
<button ref={firstRef} type="button" className="btn cp-true" disabled={!my.canCall || pending} onClick={() => act({ type: 'call', call: 'true' })} aria-label={`진짜! ${kj(r.claim!, '이', '가')} 맞다고 하기`}>
⭕ 진짜!
</button>
<button type="button" className="btn cp-false" disabled={!my.canCall || pending} onClick={() => act({ type: 'call', call: 'false' })} aria-label={`거짓말! ${kj(r.claim!, '이', '가')} 아니라고 하기`}>
❌ 거짓말!
</button>
</div>
<button type="button" className="btn btn-primary cp-peek" disabled={!my.canPeek || pending} onClick={() => act({ type: 'peek' })}>
👁 몰래 보고 넘기기
</button>
{my.peekBlocked ? <span className="cp-why">{my.peekBlocked}</span> : <span className="cp-why">몰래 보면 판정할 수 없고, 아직 안 본 사람에게 꼭 넘겨야 해요.</span>}
<span className="cp-why">맞히면 {nick(players, r.lastPasser!)}님이, 틀리면 내가 이 카드를 앞마당에 놓아요.</span>
</div>
);
}
function WaitPanel({ v, players, me }: { v: CockroachView; players: Record<string, PublicUser>; me: string | null }) {
if (v.phase === 'finished') {
const f = v.final!;
return (
<div className="cp-panel" aria-live="polite">
<strong>게임이 끝났어요</strong>
<span>{f.summary}</span>
<span className="small muted">패배: {nick(players, f.loser)}님 · 오른쪽에서 모두의 남은 손패를 볼 수 있어요.</span>
</div>
);
}
const r = v.round;
const who = nick(players, waitingOn(v)!);
const doing = r.holder === null ? '건넬 카드를 고르는 중이에요' : r.peeked ? '몰래 본 카드를 넘길 사람을 고르는 중이에요' : `"${claimLine(r.claim!)}"를 듣고 고민하는 중이에요`;
return (
<div className="cp-panel">
<strong>
{who}님이 {doing}
</strong>
{me && v.order.includes(me) && r.holder !== null && r.seen.includes(me) && <span className="small muted">나는 이 카드를 봤어요. 다시 받을 수 없어요.</span>}
<span className="cp-help">같은 벌레가 {v.loseCount}장 모이면 져요. 빨간 배지는 한 장만 더 모이면 지는 벌레예요.</span>
</div>
);
}
function MyArea({ v, me, players, deadline, offering }: { v: CockroachView; me: string; players: Record<string, PublicUser>; deadline: number | null; offering: boolean }) {
const front = v.fronts[me]!;
const active = waitingOn(v) === me;
const total = v.handCounts[me] ?? 0;
return (
<section className={`cp-me${active ? ' turn' : ''}`} aria-label="내 자리">
<div className="cp-head">
<Avatar user={userOf(players, me)} size={26} />
<span className="cp-name">{nick(players, me)} (나)</span>
{v.away[me] && <span className="cp-badge">자리 비움</span>}
{active && <TurnTimer deadline={deadline} totalMs={v.turnMs} mine size={30} />}
</div>
<div className="cp-me-row">
<div>
<span className="small muted">내 앞마당 {front.total}장</span>
<div className="cp-front" role="img" aria-label={`내 ${frontText(front, v.loseCount)}`}>
<FrontChips front={front} loseCount={v.loseCount} size={26} />
</div>
</div>
<div style={{ flex: 1 }}>
<span className="small muted">내 손패 {total}장{offering ? ' (위에서 고르세요)' : ''}</span>
{!offering && (
<div className="cp-hand" role="list" aria-label={`내 손패: ${(v.myHand ?? []).map((g) => `${KIND_KO[g.kind]} ${g.ids.length}장`).join(', ') || '없음'}`}>
{(v.myHand ?? []).map((g) => (
<span key={g.kind} className="cp-pick" role="listitem" aria-label={`${KIND_KO[g.kind]} ${g.ids.length}장`}>
<FaceCard kind={g.kind} size="sm" />
<span className="cp-count" aria-hidden>
{g.ids.length}
</span>
</span>
))}
{total === 0 && <span className="cp-empty">손패가 없어요. 카드를 건넬 차례가 오면 져요.</span>}
</div>
)}
</div>
</div>
</section>
);
}
// ---------------------------------------------------------------- board
function CockroachBoard({ view: v, me, players, act, pending, deadline }: BoardProps<CockroachView>) {
const amPlayer = !!me && v.order.includes(me);
const [announce, setAnnounce] = useState('');
const [bubbles, setBubbles] = useState<Record<string, string>>({});
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<string, string> = {};
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 (
<div className="cp-board" role="region" aria-label="바퀴벌레 포커 판">
<style>{CSS}</style>
<div className="cp-seats">
{seats.map((p) => (
<Seat key={p} id={p} v={v} me={me} players={players} deadline={deadline} bubble={bubbles[p] ?? null} />
))}
</div>
<div className="cp-mid">
<Table v={v} me={me} players={players} fresh={fresh} />
{offering || passing ? (
<GiveFlow v={v} players={players} act={act} pending={pending} />
) : responding ? (
<RespondPanel v={v} players={players} act={act} pending={pending} />
) : (
<WaitPanel v={v} players={players} me={me} />
)}
</div>
{amPlayer && <MyArea v={v} me={me!} players={players} deadline={deadline} offering={offering} />}
{amPlayer && bubbles[me!] && (
<span className="sr-only" aria-hidden>
{bubbles[me!]}
</span>
)}
<p className="sr-only" aria-live="polite">
{announce}
</p>
</div>
);
}
// ---------------------------------------------------------------- side
function CockroachSide({ view: v, me, players, act, pending, deadline }: BoardProps<CockroachView>) {
const running = v.phase !== 'finished';
const w = waitingOn(v);
const r = v.round;
return (
<>
<style>{CSS}</style>
<div className="card stack" style={{ gap: 8, padding: 12 }}>
<div className="row" style={{ justifyContent: 'space-between' }}>
<strong>{running ? `${r.no}라운드 · ${nick(players, w!)}님 차례` : '게임 끝'}</strong>
{running && <TurnTimer deadline={deadline} totalMs={v.turnMs} mine={w === me} size={36} />}
</div>
<span className="small muted">{optionsSummary(v.options, v.order.length)}</span>
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 6 }} aria-label="참가자">
{v.order.map((p) => (
<li key={p} className="row" style={{ gap: 6, flexWrap: 'nowrap', alignItems: 'center' }} aria-label={`${nick(players, p)}님: 손패 ${v.handCounts[p]}장, ${frontText(v.fronts[p]!, v.loseCount)}`}>
<Avatar user={userOf(players, p)} size={24} />
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: p === me ? 800 : 600 }} aria-hidden>
{nick(players, p)}
{w === p ? ' · 차례' : ''}
{v.final ? (v.final.loser === p ? ' · 패배' : ' · 승리') : ''}
</span>
<span className="small muted" style={{ whiteSpace: 'nowrap' }} aria-hidden>
손 {v.handCounts[p]} · 앞 {v.fronts[p]!.total}
</span>
</li>
))}
</ol>
{v.removedCount > 0 && running && <span className="small muted">2명 게임이라 카드 {v.removedCount}장을 보지 않고 뺐어요.</span>}
</div>
{v.final && <FinalCard v={v} players={players} />}
{running && v.options.claimHistory && r.claims.length > 0 && (
<div className="card stack" style={{ gap: 6, padding: 12 }}>
<strong>이번 라운드 주장</strong>
<ol className="cp-log" aria-label="이번 라운드 주장 기록">
{r.claims.map((c, i) => (
<li key={i}>
{nick(players, c.from)} → {nick(players, c.to)}: <CritterIcon kind={c.claim} size={20} /> "{claimLine(c.claim)}"{c.auto ? ' (시간 초과)' : ''}
</li>
))}
</ol>
</div>
)}
{running && v.options.quickChat && me && v.order.includes(me) && (
<div className="card stack" style={{ gap: 6, padding: 12 }}>
<strong>빠른 말</strong>
<div className="cp-say" role="group" aria-label="빠른 말">
{PHRASES.map((ph) => (
<button key={ph} type="button" disabled={!v.my?.canSay || pending} onClick={() => act({ type: 'say', phrase: ph })}>
{PHRASE_KO[ph]}
</button>
))}
</div>
<span className="small muted">한 라운드에 3번까지 말할 수 있어요.</span>
</div>
)}
<details className="card" style={{ padding: 12 }} open={running}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>도움말</summary>
<ul className="small" style={{ margin: '8px 0 0', paddingLeft: 18, display: 'grid', gap: 4 }}>
<li>같은 벌레가 앞마당에 {v.loseCount}장 모이면 져요.</li>
<li>노란 배지는 조심, 빨간 배지(!)는 한 장만 더 오면 지는 벌레예요.</li>
<li>받은 카드는 "진짜!"·"거짓말!"로 판정하거나, 몰래 보고 아직 안 본 사람에게 넘길 수 있어요.</li>
<li>맞히면 건넨 사람이, 틀리면 받은 사람이 카드를 가져가고 다음 라운드를 시작해요.</li>
</ul>
</details>
</>
);
}
function FinalCard({ v, players }: { v: CockroachView; players: Record<string, PublicUser> }) {
const f = v.final!;
return (
<div className="card stack" style={{ gap: 6, padding: 12 }}>
<strong>결과</strong>
<span>{f.summary}</span>
<span className="small muted">자세한 순위(같은 벌레가 적은 순 → 앞마당이 적은 순)</span>
<ol className="cp-log">
{f.placings.map((g, i) => (
<li key={i}>
<strong>{i + 1}위</strong> {g.map((p) => `${nick(players, p)}${p === f.loser ? ' (패배)' : ''}`).join(', ')}
</li>
))}
</ol>
{v.revealed && (
<>
<strong style={{ marginTop: 4 }}>남은 손패 공개</strong>
<ul className="cp-log">
{v.order.map((p) => (
<li key={p}>
{nick(players, p)}: {v.revealed!.hands[p]!.length === 0 ? '없음' : summarizeIds(v.revealed!.hands[p]!)}
</li>
))}
{v.revealed.removed.length > 0 && <li>뺀 카드: {summarizeIds(v.revealed.removed)}</li>}
</ul>
</>
)}
</div>
);
}
function summarizeIds(ids: string[]): string {
const counts = new Map<Kind, number>();
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, PublicUser>): 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<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>
);
}
function Options({ value: o, onChange }: OptionsProps<CockroachOptions>) {
const [more, setMore] = useState(false);
const set = (patch: Partial<CockroachOptions>) => onChange({ ...o, ...patch });
return (
<div className="stack" style={{ gap: 16 }}>
<Field label="같은 벌레 몇 장이면 패배">
<Seg label="같은 벌레 몇 장이면 패배" value={o.loseCount} options={[['auto', '자동(2명 5장, 3~6명 4장)'], [3, '3장(짧게)'], [4, '4장'], [5, '5장']]} onChange={(loseCount) => set({ loseCount })} />
</Field>
<Field label="건네기·판정 제한 시간">
<Seg label="건네기·판정 제한 시간" value={o.turnSeconds} options={[[15, '15초'], [30, '30초'], [60, '60초']]} onChange={(turnSeconds) => set({ turnSeconds })} />
</Field>
<Field label="이번 라운드 주장 기록">
<Seg label="이번 라운드 주장 기록" value={o.claimHistory} options={[[true, '보이기'], [false, '숨기기']]} onChange={(claimHistory) => set({ claimHistory })} />
</Field>
<Field label="빠른 말 버튼">
<Seg label="빠른 말 버튼" value={o.quickChat} options={[[true, '켜기'], [false, '끄기']]} onChange={(quickChat) => set({ quickChat })} />
</Field>
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
{more ? '▲ 하우스 룰 닫기' : '▼ 하우스 룰'}
</button>
{more && (
<>
<Field label="8종을 모두 모아도 패배">
<Seg label="8종을 모두 모아도 패배" value={o.eightKindsRule} options={[[false, '끄기'], [true, '켜기']]} onChange={(eightKindsRule) => set({ eightKindsRule })} />
</Field>
<Field label="2명일 때 보고 되돌려 주기">
<Seg label="2명일 때 보고 되돌려 주기" value={o.twoPlayerPass} options={[[false, '안 함'], [true, '허용']]} onChange={(twoPlayerPass) => set({ twoPlayerPass })} />
</Field>
</>
)}
</div>
);
}
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 (
<div className="stack">
<p>
벌레 카드를 뒤집어 건네며 "<strong>이건 쥐야!</strong>"처럼 말하고, 받은 사람은 그 말이 진짜인지 거짓말인지 맞히는 눈치 게임이에요. 2~6명이 할 수 있어요. 같은 벌레가 내 앞마당에 <strong>{lose}</strong> 모이면 지고, 나머지는 모두
이겨요.
</p>
<div className="row" style={{ gap: 6, flexWrap: 'wrap' }} role="img" aria-label={`벌레 8종: ${KINDS.map((k) => KIND_KO[k]).join(', ')}, 종류마다 8장`}>
{KINDS.map((k) => (
<FaceCard key={k} kind={k} size="sm" />
))}
</div>
<p>
<strong>준비</strong>: 벌레 8종 × 8장 = 64장을 섞어 모두 나눠요(2명이면 10장을 보지 않고 빼고 27장씩). 첫 사람은 무작위로 정해요.
</p>
<p>
<strong>건네기</strong>: 차례인 사람이 손패 1장을 골라 다른 사람에게 뒷면으로 건네며 벌레 이름 하나를 말해요. 진짜를 말해도, 거짓말을 해도 돼요.
</p>
<p>
<strong>받은 사람</strong>은 둘 중 하나를 골라요.
</p>
<ul>
<li>
<strong>진짜! / 거짓말!</strong>: 카드를 공개해요. 맞히면 건넨 사람이, 틀리면 내가 그 카드를 앞마당에 앞면으로 놓아요.
</li>
<li>
<strong>몰래 보고 넘기기</strong>: 카드를 혼자 보고, 아직 이 카드를 보지 않은 사람에게 다시 말하며 넘겨요. 처음 건넨 사람과 이미 본 사람에게는 넘길 수 없어요. 모두 봤다면 꼭 판정해야 해요.
{o?.twoPlayerPass ? ' 2명일 때는 한 번만 되돌려 줄 수 있어요.' : ' 2명일 때는 넘기기 없이 항상 판정해요.'}
</li>
</ul>
<p>카드를 앞마당에 놓은 사람이 다음 라운드를 시작해요. 그때 손에 카드가 한 장도 없으면 바로 져요.</p>
{o?.eightKindsRule && <p>하우스 룰: 앞마당에 8종을 모두 한 장 이상 모아도 져요.</p>}
<p className="muted">
시간 안에 고르지 않으면 자동으로 진행돼요(건네기: 무작위 카드를 다음 사람에게, 판정: 무작위, 넘기기: 아직 안 본 다음 사람에게 실제 벌레 이름으로). 세 번 연속 시간을 넘기면 제한 시간이 5초로 줄어요. 게임을 나가면 그 사람이 진
것으로 끝나요.
</p>
</div>
);
}
const ui: GameUI<CockroachView, CockroachOptions> = {
Board: CockroachBoard,
Side: CockroachSide,
statusText,
isMyTurn,
Options,
optionsSummary: (o) => optionsSummary(o),
Rules,
};
export default ui;