/** 뱅! 화면 (docs/games/bang.md §9). */ import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react'; import type { BangOptions, BangView, Card, CardFace, CardKind, CharacterId, LogEntry, PlayOption, PlayerView, Prompt, Role, Team } from '@bg/games/bang'; 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'; // ---------------------------------------------------------------- names (display names live only here, docs §12) const CARD_INFO: Record = { bang: { name: '뱅!', icon: '💥', text: '사거리 안 1명을 쏴요' }, missed: { name: '빗나감!', icon: '💨', text: '뱅! 1발을 피해요' }, beer: { name: '맥주', icon: '🍺', text: '생명 +1' }, saloon: { name: '살롱', icon: '🍻', text: '모두 생명 +1' }, stagecoach: { name: '역마차', icon: '🐴', text: '카드 2장 받기' }, wellsFargo: { name: '웰스 파고', icon: '📦', text: '카드 3장 받기' }, generalStore: { name: '잡화점', icon: '🏪', text: '모두 1장씩 골라요' }, panic: { name: '패닉!', icon: '😱', text: '거리 1, 카드 1장 뺏기' }, catBalou: { name: '캣 벌루', icon: '🧹', text: '누구든 카드 1장 버리게' }, duel: { name: '결투', icon: '⚔️', text: '번갈아 뱅!, 못 내면 −1' }, indians: { name: '인디언!', icon: '🏹', text: '모두 뱅! 내거나 −1' }, gatling: { name: '개틀링', icon: '🔥', text: '다른 모두에게 뱅!' }, barrel: { name: '술통', icon: '🛢️', text: '♥ 판정이면 피해요' }, mustang: { name: '머스탱', icon: '🐎', text: '남이 볼 때 거리 +1' }, scope: { name: '조준경', icon: '🔭', text: '내가 볼 때 거리 −1' }, jail: { name: '감옥', icon: '🔒', text: '♥ 아니면 턴을 쉬어요' }, dynamite: { name: '다이너마이트', icon: '🧨', text: '♠2~9 판정이면 −3' }, volcanic: { name: '연발 권총', icon: '🔫', text: '사거리 1, 뱅! 무제한' }, schofield: { name: '리볼버', icon: '🔫', text: '사거리 2' }, remington: { name: '장총', icon: '🔫', text: '사거리 3' }, carabine: { name: '카빈총', icon: '🔫', text: '사거리 4' }, winchester: { name: '라이플', icon: '🔫', text: '사거리 5' }, }; const BLUE: CardKind[] = ['barrel', 'mustang', 'scope', 'jail', 'dynamite', 'volcanic', 'schofield', 'remington', 'carabine', 'winchester']; const CHAR_INFO: Record = { bart: { name: '바트 캐시디', text: '생명을 1 잃을 때마다 카드 1장을 받아요.' }, blackJack: { name: '블랙 잭', text: '두 번째로 받는 카드를 보여 주고 ♥·♦면 1장 더 받아요.' }, calamity: { name: '캘러미티 자넷', text: '뱅!과 빗나감!을 서로 바꿔 쓸 수 있어요.' }, elGringo: { name: '엘 그링고', text: '남의 카드로 생명을 잃으면 그 사람 손패 1장을 가져와요.' }, jesse: { name: '제시 존스', text: '첫 카드를 다른 사람 손패에서 받을 수 있어요.' }, jourdonnais: { name: '주르도네', text: '늘 술통이 있는 것처럼 판정해요.' }, kit: { name: '킷 칼슨', text: '카드 3장을 보고 2장을 골라 받아요.' }, lucky: { name: '럭키 듀크', text: '판정할 때 2장을 보고 좋은 쪽을 써요.' }, paul: { name: '폴 리그레트', text: '남이 나를 볼 때 거리 +1이에요.' }, pedro: { name: '페드로 라미레스', text: '첫 카드를 버린 카드 맨 위에서 받을 수 있어요.' }, rose: { name: '로즈 둘란', text: '내가 남을 볼 때 거리 −1이에요.' }, sid: { name: '시드 케첨', text: '카드 2장을 버리고 생명 1을 회복해요(언제든).' }, slab: { name: '슬랩 더 킬러', text: '내 뱅!을 막으려면 빗나감! 2장이 필요해요.' }, suzy: { name: '수지 라파예트', text: '손패가 0장이 되면 바로 1장 받아요.' }, vulture: { name: '벌처 샘', text: '누가 쓰러지면 그 사람 카드를 모두 가져와요.' }, willy: { name: '윌리 더 키드', text: '뱅!을 몇 장이든 낼 수 있어요.' }, }; const ROLE_INFO: Record = { sheriff: { name: '보안관', icon: '⭐', goal: '무법자와 배신자를 모두 잡으면 이겨요.', tip: '모두가 보안관을 알아요. 부관을 찾아 함께 버티세요.', color: '#b45309' }, deputy: { name: '부관', icon: '🛡️', goal: '보안관과 함께 무법자와 배신자를 잡으면 이겨요.', tip: '부관은 보안관을 지켜야 해요. 정체를 너무 빨리 들키지 마세요.', color: '#2563eb' }, outlaw: { name: '무법자', icon: '🎯', goal: '보안관을 쓰러뜨리면 이겨요.', tip: '다른 무법자와 힘을 모아 보안관을 노리세요.', color: '#b91c1c' }, renegade: { name: '배신자', icon: '🃏', goal: '마지막에 혼자 남으면 이겨요.', tip: '처음엔 보안관 편인 척하고, 끝에 보안관과 1대1로 겨루세요.', color: '#7c3aed' }, }; const TEAM_KO: Record = { law: '보안관 팀', outlaws: '무법자', renegade: '배신자' }; const SUIT_SYMBOL = { S: '♠', H: '♥', D: '♦', C: '♣' } as const; const SUIT_KO = { S: '스페이드', H: '하트', D: '다이아몬드', C: '클로버' } as const; 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 sr = (f: CardFace) => `${f.suit === 'H' || f.suit === 'D' ? 'r' : 'b'}`; const faceShort = (f: CardFace) => `${CARD_INFO[f.kind].name}(${SUIT_SYMBOL[f.suit]}${f.rank})`; const faceAria = (f: CardFace) => `${CARD_INFO[f.kind].name}, ${SUIT_KO[f.suit]} ${f.rank}, ${CARD_INFO[f.kind].text}`; // ---------------------------------------------------------------- styles const CSS = ` .bg { width: 100%; max-width: 1180px; display: flex; flex-direction: column; gap: 12px; align-self: center; } .bg-table { position: relative; width: 100%; aspect-ratio: 16 / 8.2; min-height: 460px; border-radius: 200px; border: 10px solid var(--wood); background: radial-gradient(ellipse at center, rgba(255,255,255,.13), rgba(255,255,255,0) 62%), var(--felt); box-shadow: var(--shadow); } .bg-seat { position: absolute; left: var(--x); top: var(--y); transform: translate(-50%, -50%); width: var(--sw, 178px); z-index: 1; background: var(--surface); color: var(--text); border-radius: 12px; padding: 6px 8px; box-shadow: 0 2px 8px rgba(0,0,0,.28); border: 3px solid transparent; display: flex; flex-direction: column; gap: 3px; font-size: .84rem; text-align: left; font: inherit; font-size: .84rem; } button.bg-seat { cursor: pointer; } .bg-seat.turn { border-color: var(--focus); } .bg-seat.react { border-color: #f97316; border-style: dashed; } .bg-seat.me { border-color: var(--primary); } .bg-seat.dead { opacity: .62; filter: grayscale(.7); } .bg-seat[data-target='ok'] { border-color: var(--success); box-shadow: 0 0 0 4px rgba(21,128,61,.45), 0 2px 8px rgba(0,0,0,.28); } .bg-seat[data-target='ok'][aria-pressed='true'] { box-shadow: 0 0 0 6px var(--success), 0 2px 8px rgba(0,0,0,.28); } .bg-seat[data-target='no'] { opacity: .55; } .bg-seat .nm { font-weight: 800; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; } .bg-row { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; min-width: 0; } .bg-row.nw { flex-wrap: nowrap; } .bg-char { font-weight: 700; font-size: .8rem; color: var(--text-sub); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .bg-role { display: inline-flex; align-items: center; gap: 2px; border-radius: 999px; padding: 0 7px; font-weight: 800; font-size: .74rem; line-height: 1.6; color: #fff; white-space: nowrap; } .bg-role.hidden { background: var(--surface-2); color: var(--text-sub); border: 1px dashed var(--border); } .bg-hp { display: inline-flex; gap: 2px; align-items: center; } .bg-hp i { width: 9px; height: 15px; border-radius: 5px 5px 2px 2px; background: #dc2626; border: 1px solid #7f1d1d; display: inline-block; } .bg-hp i.e { background: transparent; border: 1px dashed var(--text-sub); } .bg-cnt { display: inline-flex; align-items: center; gap: 3px; font-weight: 800; font-variant-numeric: tabular-nums; } .bg-cnt b { display: inline-block; width: 11px; height: 15px; border-radius: 2px; background: #7c2d12; border: 1.5px solid #fdba74; } .bg-eqs { display: flex; flex-wrap: wrap; gap: 3px; } .bg-eq { display: inline-flex; align-items: center; gap: 2px; border-radius: 6px; padding: 0 5px; font-size: .72rem; font-weight: 700; line-height: 1.55; background: #dbeafe; color: #1e3a8a; border: 1px solid #93c5fd; white-space: nowrap; } .bg-eq[data-k='jail'], .bg-eq[data-k='dynamite'] { background: #fee2e2; color: #7f1d1d; border-color: #fca5a5; } .bg-dist { font-size: .74rem; font-weight: 700; color: var(--text-sub); } .bg-dist.in { color: var(--success); } .bg-why { font-size: .72rem; font-weight: 700; color: var(--danger); line-height: 1.25; } .bg-badge { display: inline-block; border-radius: 999px; padding: 0 7px; font-weight: 800; font-size: .72rem; line-height: 1.6; color: #fff; } .bg-center { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 44%; display: flex; flex-direction: column; align-items: center; gap: 6px; color: #fff; text-align: center; text-shadow: 0 1px 2px rgba(0,0,0,.55); } .bg-piles { display: flex; gap: 12px; align-items: center; justify-content: center; } .bg-back { width: 58px; height: 82px; border-radius: 8px; background: repeating-linear-gradient(45deg, #7c2d12 0 6px, #9a3412 6px 12px); border: 3px solid #fdba74; display: grid; place-items: center; font-weight: 900; color: #fff; font-size: 1rem; box-shadow: 0 2px 6px rgba(0,0,0,.35); } .bg-chip { display: inline-flex; align-items: center; gap: 6px; background: rgba(0,0,0,.38); border-radius: 999px; padding: 2px 10px; font-weight: 700; font-size: .88rem; } .bg-chip.ok { background: rgba(21,128,61,.85); } .bg-chip.bad { background: rgba(185,28,28,.85); } .bg-card { position: relative; flex: none; width: var(--cw, 96px); height: calc(var(--cw, 96px) * 1.42); border-radius: 10px; background: #fffaf0; color: #1f2937; border: 4px solid #92400e; box-shadow: 0 2px 6px rgba(0,0,0,.25); display: flex; flex-direction: column; align-items: center; justify-content: space-between; padding: 4px 4px 5px; text-align: center; overflow: hidden; font: inherit; } .bg-card[data-blue='true'] { border-color: #1d4ed8; } .bg-card .ix { align-self: flex-start; font-weight: 900; font-size: .82rem; line-height: 1; } .bg-card .ix.r { color: #dc2626; } .bg-card .ic { font-size: calc(var(--cw, 96px) * .34); line-height: 1; } .bg-card .cn { font-weight: 900; font-size: .9rem; line-height: 1.1; word-break: keep-all; } .bg-card .ct { font-size: .68rem; line-height: 1.15; color: #4b5563; font-weight: 600; word-break: keep-all; } .bg-card.sm { --cw: 62px; } .bg-card.sm .cn { font-size: .74rem; } .bg-card.sm .ct { display: none; } .bg-card.sm .ix { font-size: .7rem; } button.bg-card { cursor: pointer; transition: transform .12s ease, opacity .12s ease; } button.bg-card[data-ok='false'] { opacity: .55; filter: grayscale(.35); } button.bg-card[data-ok='true'] { box-shadow: 0 0 0 3px var(--success), 0 2px 6px rgba(0,0,0,.25); } button.bg-card[aria-pressed='true'] { transform: translateY(-12px); box-shadow: 0 0 0 4px var(--primary), 0 4px 10px rgba(0,0,0,.3); } button.bg-card:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } .bg-me { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; } .bg-mehead { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } .bg-rolecard { min-height: 48px; min-width: 112px; border-radius: 12px; border: 2px dashed var(--border); background: var(--surface-2); color: var(--text); font-weight: 800; padding: 4px 10px; cursor: pointer; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; touch-action: none; font: inherit; font-weight: 800; } .bg-rolecard[data-show='true'] { color: #fff; border-style: solid; } .bg-hand { display: flex; gap: 8px; overflow-x: auto; padding: 16px 4px 8px; scrollbar-width: thin; } .bg-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } .bg-actions .btn { flex: 1 1 auto; min-height: 52px; } .bg-hint { margin: 0; font-size: .92rem; } .bg-picks { display: flex; flex-wrap: wrap; gap: 8px; } .bg-picks .btn { min-height: 48px; } .bg-modal-back { position: fixed; inset: 0; background: rgba(0,0,0,.45); z-index: 40; display: flex; align-items: center; justify-content: center; padding: 12px; } .bg-modal { width: min(560px, 100%); max-height: calc(100vh - 24px); overflow: auto; background: var(--surface); color: var(--text); border-radius: 18px; box-shadow: 0 10px 40px rgba(0,0,0,.45); padding: 16px; display: flex; flex-direction: column; gap: 10px; border: 4px solid #f97316; } .bg-modal h2 { margin: 0; font-size: 1.3rem; } .bg-modal .btn { min-height: 56px; } .bg-modal .bg-hand { padding-top: 14px; } .bg-chars { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } .bg-chars button { min-height: 120px; border-radius: 14px; border: 3px solid var(--border); background: var(--surface-2); color: var(--text); padding: 10px; text-align: left; cursor: pointer; font: inherit; } .bg-chars button strong { display: block; font-size: 1.1rem; } .bg-log { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 3px; max-height: 300px; overflow: auto; } .bg-rules td, .bg-rules th { padding: 4px 6px; text-align: left; vertical-align: middle; border-bottom: 1px solid var(--border); } .bg-last { font-size: .86rem; max-width: 100%; overflow: hidden; text-overflow: ellipsis; } @media (max-width: 719px) { .bg-table { aspect-ratio: auto; min-height: 0; border-radius: 18px; border-width: 6px; padding: 8px; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; } .bg-seat { position: static; transform: none; width: auto; padding: 4px 5px; font-size: .76rem; gap: 2px; border-width: 2px; } .bg-seat.me { display: none; } .bg-seat .nm { font-size: .8rem; } .bg-char { font-size: .7rem; } .bg-row.rc { flex-wrap: wrap; row-gap: 2px; } .bg-hp i { width: 7px; height: 12px; } .bg-eq { font-size: .64rem; padding: 0 3px; } .bg-center { position: static; transform: none; width: auto; grid-column: 1 / -1; order: -1; flex-direction: row; flex-wrap: wrap; justify-content: center; gap: 6px; padding: 2px 0 4px; } .bg-back { width: 40px; height: 56px; font-size: .85rem; } .bg-center .bg-card.sm { --cw: 40px; } .bg-center .bg-card.sm .cn { display: none; } .bg-chip { font-size: .78rem; } .bg-card { --cw: 86px; } .bg-me { padding: 8px; } } @media (prefers-reduced-motion: reduce) { button.bg-card { transition: none; } } `; // ---------------------------------------------------------------- small parts function CardArt({ card, small, label }: { card: CardFace; small?: boolean; label?: boolean }) { const info = CARD_INFO[card.kind]; return ( ); } function CardFaceInner({ card, info }: { card: CardFace; info: (typeof CARD_INFO)[CardKind] }) { return ( <> {card.rank} {SUIT_SYMBOL[card.suit]} {info.icon} {info.name} {info.text} ); } function Hp({ life, max }: { life: number; max: number }) { const n = Math.max(0, life); return ( {Array.from({ length: max }, (_, i) => ( ))} ); } function RoleBadge({ role, alive }: { role: Role | null; alive: boolean }) { if (!role) return ? 역할 비밀; const r = ROLE_INFO[role]; return ( {r.icon} {r.name} {!alive ? '(공개)' : ''} ); } function Equip({ cards }: { cards: Card[] }) { if (!cards.length) return null; return ( {cards.map((c) => ( {CARD_INFO[c.kind].icon} {CARD_INFO[c.kind].name} ))} ); } const REACT_KO: Record = { bang: '빗나감! 고르는 중', indians: '인디언! 막는 중', duel: '결투 중', store: '잡화점 고르는 중', dying: '쓰러지기 직전!', kitKeep: '카드 고르는 중', choose2: '캐릭터 고르는 중', }; /** My own hidden role stays off the table (only the hold-to-see role card shows it, docs §9). */ function shownRole(p: PlayerView, v: BangView, me: string | null): Role | null { if (p.id === me && p.alive && !v.winner && p.role !== 'sheriff') return null; return p.role; } function seatLabel(p: PlayerView, players: Record, me: string | null, role: Role | null): string { const parts = [`${nick(players, p.id)}${p.id === me ? '(나)' : ''}`]; if (p.character) parts.push(CHAR_INFO[p.character].name); parts.push(role ? ROLE_INFO[role].name : '역할 비밀'); if (!p.alive) parts.push('쓰러짐'); else parts.push(`생명 ${p.life}/${p.maxLife}`, `카드 ${p.handCount}장`); if (p.inPlay.length) parts.push(`장착: ${p.inPlay.map((c) => CARD_INFO[c.kind].name).join(', ')}`); if (p.distance !== null) parts.push(`거리 ${p.distance}${p.inRange ? ', 사거리 안' : ''}`); if (p.isTurn) parts.push('차례'); if (p.reacting) parts.push(REACT_KO[p.reacting] ?? ''); if (p.away) parts.push('자리 비움'); return parts.join(', '); } interface TargetMark { ok: boolean; reason?: string; selected: boolean; } function Seat({ p, v, players, me, mark, onPick, style, }: { p: PlayerView; v: BangView; players: Record; me: string | null; mark: TargetMark | null; onPick: (id: string) => void; style?: CSSProperties; }) { const u = userOf(players, p.id); const r = v.reactions.find((x) => x.who === p.id); const timer = r ? ( ) : p.isTurn && !v.reactions.length && !v.winner ? ( ) : null; const cls = `bg-seat${p.isTurn ? ' turn' : ''}${r ? ' react' : ''}${p.id === me ? ' me' : ''}${!p.alive ? ' dead' : ''}`; const body = ( <> {u.nickname} {p.id === me ? ' (나)' : ''} {timer} {p.character ? CHAR_INFO[p.character].name : '캐릭터 고르는 중'} {p.alive ? ( {p.handCount} {p.distance !== null && v.options.showDistanceHelper && 거리 {p.distance}} ) : ( 쓰러짐 )} {(p.away || (r && p.id !== me)) && ( {r && p.id !== me && {REACT_KO[r.kind]}} {p.away && 자리 비움} )} {mark && !mark.ok && mark.reason && {shortReason(mark.reason)}} ); if (mark?.ok) { return ( ); } return (
{body}
); } /** "사거리가 닿지 않아요(거리 3, 사거리 2)." → "거리 3 / 사거리 2" */ function shortReason(r: string): string { const m = /거리 (\d+), 사거리 (\d+)/.exec(r); if (m) return `거리 ${m[1]} / 사거리 ${m[2]}`; return r.replace(/\.$/, ''); } // ---------------------------------------------------------------- table function Center({ v, players }: { v: BangView; players: Record }) { const top = v.discardTop[v.discardTop.length - 1]; const chk = v.lastCheck; const PURPOSE: Record = { dynamite: '다이너마이트', jail: '감옥', barrel: '술통', jourdonnais: '주르도네' }; const waitingFor = v.reactions.length ? [...new Set(v.reactions.map((r) => r.who))] : []; const last = v.log[v.log.length - 1]; return (
{v.deckCount} {top ? : 버린 카드 없음}
{v.phase !== 'draft' && {v.round}라운드} {chk && ( {nick(players, chk.player)}님 {PURPOSE[chk.purpose]} 판정: {chk.cards.map((c) => `${SUIT_SYMBOL[c.suit]}${c.rank}`).join(' · ')} → {chk.success ? '성공' : '실패'} )} {waitingFor.length > 0 && {waitingFor.map((id) => `${nick(players, id)}님`).join(', ')} 기다리는 중…} {last && {logText(last, players)}}
); } // ---------------------------------------------------------------- my area function useHold(): [boolean, Record] { const [show, setShow] = useState(false); const timer = useRef | null>(null); useEffect(() => () => void (timer.current && clearTimeout(timer.current)), []); return [ show, { onPointerDown: () => setShow(true), onPointerUp: () => setShow(false), onPointerLeave: () => setShow(false), onPointerCancel: () => setShow(false), onContextMenu: (e: React.MouseEvent) => e.preventDefault(), onKeyDown: (e: KeyboardEvent) => { if (e.key !== 'Enter' && e.key !== ' ') return; e.preventDefault(); setShow(true); if (timer.current) clearTimeout(timer.current); timer.current = setTimeout(() => setShow(false), 2500); }, }, ]; } function RoleCard({ role, hints, always }: { role: Role; hints: boolean; always: boolean }) { const [held, handlers] = useHold(); const show = held || always; const r = ROLE_INFO[role]; return ( {show && ( {r.goal} {hints ? ` ${r.tip}` : ''} )} ); } interface Sel { cardId: string; as?: 'bang'; target?: string; targetCard?: string; } function playLabel(o: PlayOption, card: Card, sel: Sel | null, players: Record, me: PlayerView | undefined): string { const name = o.as ? `${CARD_INFO[card.kind].name}을 뱅!으로` : CARD_INFO[card.kind].name; if (!o.targets) return BLUE.includes(card.kind) ? `${name} 앞에 놓기` : `${name} 내기`; if (!sel?.target) return '대상을 골라 주세요'; const t = `${nick(players, sel.target)}님`; if (card.kind === 'panic' || card.kind === 'catBalou') { if (!sel.targetCard) return '카드를 골라 주세요'; const what = sel.targetCard === 'hand' ? '손패 1장' : CARD_INFO[me?.inPlay.find((c) => c.id === sel.targetCard)?.kind ?? 'bang'].name; return `${t} ${what}에 ${name}`; } return o.as || card.kind === 'bang' ? `${t}에게 뱅!` : card.kind === 'jail' ? `${t}을 감옥에` : `${t}에게 ${name}`; } function HandCards({ hand, okOf, picked, onTap, label, }: { hand: Card[]; okOf?: (c: Card) => boolean | undefined; picked: (c: Card) => boolean; onTap: (c: Card) => void; label: string; }) { const onKey = (e: KeyboardEvent) => { if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; const btns = Array.from(e.currentTarget.querySelectorAll('button.bg-card')); const i = btns.indexOf(document.activeElement as HTMLButtonElement); if (i < 0) return; e.preventDefault(); btns[(i + (e.key === 'ArrowRight' ? 1 : btns.length - 1)) % btns.length]?.focus(); }; return (
{hand.map((c) => { const ok = okOf?.(c); return ( ); })} {hand.length === 0 && 손에 카드가 없어요.}
); } function MyArea({ v, act, pending, players, sel, setSel, }: { v: BangView; act: (a: unknown) => void; pending: boolean; players: Record; sel: Sel | null; setSel: (s: Sel | null) => void; }) { const my = v.my!; const meP = v.players.find((p) => p.id === v.me); const [multi, setMulti] = useState([]); const [sidMode, setSidMode] = useState(false); const [note, setNote] = useState(''); const prompt = my.prompt; const discarding = prompt?.kind === 'discard'; const modal = prompt && prompt.kind !== 'discard' && prompt.kind !== 'draw'; const key = `${v.current}-${v.phase}-${v.reactions.length}-${my.hand.length}`; useEffect(() => { setMulti([]); setSidMode(false); setNote(''); }, [key]); const optOf = (id: string) => my.plays.find((o) => o.cardId === id); const hints = v.options.beginnerHints; const playing = my.plays.length > 0; const tap = (c: Card) => { if (pending) return; if (discarding || sidMode) { const need = discarding ? (prompt as Extract).count : 2; setMulti((m) => (m.includes(c.id) ? m.filter((x) => x !== c.id) : m.length < need ? [...m, c.id] : m)); return; } if (!playing) return; const o = optOf(c.id); if (!o) return; if (!o.ok) { setSel(null); setNote(`${CARD_INFO[c.kind].name}: ${o.reason ?? '지금은 쓸 수 없어요.'}`); return; } setNote(''); if (sel?.cardId === c.id && !o.targets) { // Second tap on an untargeted card plays it. act({ type: 'play', cardId: c.id, ...(o.as ? { as: o.as } : {}) }); setSel(null); return; } setSel(sel?.cardId === c.id ? null : { cardId: c.id, ...(o.as ? { as: o.as } : {}) }); }; const selCard = sel ? my.hand.find((c) => c.id === sel.cardId) : undefined; const selOpt = sel ? optOf(sel.cardId) : undefined; const tOpt = sel?.target ? selOpt?.targets?.find((t) => t.id === sel.target) : undefined; const needsCard = !!selCard && (selCard.kind === 'panic' || selCard.kind === 'catBalou'); const ready = !!selOpt?.ok && (!selOpt.targets || (!!tOpt?.ok && (!needsCard || !!sel?.targetCard))); const targetPlayer = sel?.target ? v.players.find((p) => p.id === sel.target) : undefined; const confirm = () => { if (!sel || !ready) return; act({ type: 'play', cardId: sel.cardId, ...(sel.as ? { as: sel.as } : {}), ...(sel.target ? { target: sel.target } : {}), ...(sel.targetCard ? { targetCard: sel.targetCard } : {}) }); setSel(null); }; let hint = note; if (!hint && selCard && selOpt) { if (selOpt.targets && !sel?.target) hint = `${CARD_INFO[selCard.kind].name}: 초록색 테두리인 사람을 눌러 대상을 골라 주세요.`; else if (hints) hint = `${CARD_INFO[selCard.kind].name}: ${CARD_INFO[selCard.kind].text}`; } if (!hint && playing && hints) hint = '초록 테두리 카드를 쓸 수 있어요. 흐린 카드를 누르면 쓸 수 없는 이유를 알려 줘요.'; return (
{my.character ? CHAR_INFO[my.character].name : '캐릭터를 고르는 중'} {meP && meP.alive && ( <> {' '} )} {my.character && {CHAR_INFO[my.character].text}} 사거리 {my.range} {my.unlimitedBang ? ' · 뱅! 무제한' : v.current === v.me && v.phase === 'play' ? ` · 이번 턴 뱅! ${v.bangsPlayed ? '사용함' : '1번 가능'}` : ''} {meP && }
{meP && !meP.alive && !v.winner && 쓰러졌어요. 남은 게임을 지켜봐요.} {prompt?.kind === 'draw' && } {meP?.alive && ( (discarding || sidMode ? undefined : playing ? !!optOf(c.id)?.ok : undefined)} picked={(c) => (discarding || sidMode ? multi.includes(c.id) : sel?.cardId === c.id)} onTap={tap} /> )} {needsCard && tOpt?.ok && tOpt.cards && targetPlayer && (
{tOpt.cards.map((tc) => { const eq = targetPlayer.inPlay.find((c) => c.id === tc); return ( ); })}
)} {hint && !modal && (

{hint}

)}
{discarding && ( <> 손패가 생명보다 많아요. 버릴 카드 {(prompt as Extract).count}장을 골라 주세요. )} {sidMode && ( <> )} {playing && !sidMode && ( <> {sel && ( )} {sel && ( )} )} {my.canSidHeal && !sidMode && !modal && ( )}
); } function DrawPanel({ prompt, act, pending, players }: { prompt: Extract; act: (a: unknown) => void; pending: boolean; players: Record }) { return (
내 차례예요. 첫 카드를 어디서 받을까요?
{prompt.pedro && ( )} {prompt.jesseTargets.map((id) => ( ))}
); } // ---------------------------------------------------------------- reaction prompt function PromptModal({ v, act, pending, players }: { v: BangView; act: (a: unknown) => void; pending: boolean; players: Record }) { const my = v.my!; const p = my.prompt; const [picked, setPicked] = useState([]); const [sid, setSid] = useState(false); const ref = useRef(null); const key = p ? `${p.kind}-${v.reactions.length}-${my.hand.length}` : ''; useEffect(() => { setPicked([]); setSid(false); }, [key]); useEffect(() => { if (p) ref.current?.querySelector('button:not([disabled])')?.focus(); }, [p?.kind]); // eslint-disable-line react-hooks/exhaustive-deps if (!p || p.kind === 'discard' || p.kind === 'draw') return null; const r = v.reactions.find((x) => x.who === v.me); const timer = r ? : null; const n = (id: string) => `${nick(players, id)}님`; const hand = my.hand; const sidBox = my.canSidHeal && ( <> {!sid ? ( ) : ( <> 버릴 카드 2장을 골라 주세요. picked.includes(c.id)} onTap={(c) => setPicked((m) => (m.includes(c.id) ? m.filter((x) => x !== c.id) : m.length < 2 ? [...m, c.id] : m))} label="버릴 카드" /> )} ); let title = ''; let body: ReactNode = null; switch (p.kind) { case 'bang': { const cards = p.options .map((id) => hand.find((c) => c.id === id)!) .sort((a, b) => Number(a.kind !== 'missed') - Number(b.kind !== 'missed')) .slice(0, p.needed); const can = p.options.length >= p.needed; title = p.via === 'gatling' ? `${n(p.source)}의 개틀링!` : `${n(p.source)}이 뱅!을 쐈어요`; body = ( <>

{p.needed > 1 ? `빗나감! ${p.needed}장이 필요해요(슬랩 더 킬러). ` : ''} {can ? '빗나감!을 낼까요?' : p.options.length > 0 ? '빗나감!이 모자라서 막을 수 없어요.' : '막을 카드가 없어요.'}

{can && ( )} {p.canBarrel && ( )}
{sidBox} ); break; } case 'indians': case 'duel': { const card = p.options.map((id) => hand.find((c) => c.id === id)!).sort((a, b) => Number(a.kind !== 'bang') - Number(b.kind !== 'bang'))[0]; title = p.kind === 'indians' ? `${n(p.source)}의 인디언!` : `결투 — ${n(p.opponent)}과 겨루는 중`; body = ( <>

{p.kind === 'indians' ? '뱅! 1장을 버리거나 생명 1을 잃어요.' : '뱅! 1장을 내면 상대 차례가 돼요. 못 내면 생명 1을 잃어요.'}

{card && ( )}
{sidBox} ); break; } case 'dying': { const can = p.beerAllowed && p.beers.length >= p.needed; title = '쓰러지기 직전이에요!'; body = ( <>

{p.beerAllowed ? `맥주 ${p.needed}장을 마시면 살아나요.` : '두 명만 남아서 맥주로는 살아날 수 없어요.'} {p.beerAllowed && !can ? ' 맥주가 모자라요.' : ''}

{can && ( )}
{sidBox} ); break; } case 'store': title = '잡화점: 카드 1장을 골라 주세요'; body = false} onTap={(c) => !pending && act({ type: 'storePick', cardId: c.id })} label="잡화점 카드" />; break; case 'kitKeep': title = '3장 중 2장을 골라 주세요'; body = ( <> 고르지 않은 1장은 카드 더미 맨 위로 돌아가요. picked.includes(c.id)} onTap={(c) => setPicked((m) => (m.includes(c.id) ? m.filter((x) => x !== c.id) : m.length < 2 ? [...m, c.id] : m))} label="킷 칼슨 카드" /> ); break; case 'choose2': title = '캐릭터를 골라 주세요'; body = (
{p.options.map((c) => ( ))}
); break; } return (

{title}

{timer}
{body}
); } // ---------------------------------------------------------------- log const PURPOSE_KO: Record = { dynamite: '다이너마이트', jail: '감옥', barrel: '술통', jourdonnais: '주르도네' }; const ROLE_KO = (r: Role) => ROLE_INFO[r].name; function logText(e: LogEntry, players: Record): string { const n = (id: string) => `${nick(players, id)}님`; switch (e.t) { case 'turn': return `${e.round}라운드 · ${n(e.p)} 차례`; case 'play': return `${n(e.p)}: ${CARD_INFO[e.card.kind].name}${e.as ? '(뱅!으로)' : ''}${e.target ? ` → ${n(e.target)}` : ''}`; case 'equip': return e.target ? `${n(e.p)}이 ${n(e.target)}을 감옥에 넣었어요` : `${n(e.p)}이 ${CARD_INFO[e.card.kind].name}을 앞에 놓았어요${e.replaced ? `(${CARD_INFO[e.replaced.kind].name} 버림)` : ''}`; case 'check': return `${n(e.p)} ${PURPOSE_KO[e.purpose]} 판정: ${e.cards.map((c) => `${SUIT_SYMBOL[c.suit]}${c.rank}`).join(' · ')} → ${e.success ? '성공' : '실패'}`; case 'dynamite': return e.exploded ? `다이너마이트가 ${n(e.p)} 앞에서 터졌어요!` : `다이너마이트가 ${n(e.to!)}에게 넘어갔어요`; case 'jail': return e.escaped ? `${n(e.p)}이 감옥에서 나왔어요` : `${n(e.p)}은 감옥에서 이번 턴을 쉬어요`; case 'missed': return `${n(e.p)}이 ${e.cards.map((c) => CARD_INFO[c.kind].name).join(' + ')}로 피했어요`; case 'respond': return e.why === 'indians' ? `${n(e.p)}이 뱅!을 버려 인디언!을 막았어요` : `${n(e.p)}이 결투에서 뱅!을 냈어요`; case 'barrelSave': return `${n(e.p)}이 판정 성공으로 피했어요`; case 'hit': return `${n(e.p)}이 생명 ${e.n}을 잃었어요 (남은 생명 ${Math.max(0, e.life)})`; case 'heal': return e.why === 'saloon' ? `${n(e.p)} 생명 +${e.n} (살롱)` : e.why === 'sid' ? `${n(e.p)}이 카드 2장을 버려 생명 +1` : `${n(e.p)}이 맥주로 생명 +${e.n}`; case 'draw': return `${n(e.p)}이 카드 ${e.n}장을 받았어요${e.why === 'reward' ? ' (무법자 처치 보상)' : e.why === 'bart' ? ' (바트 캐시디)' : e.why === 'suzy' ? ' (수지 라파예트)' : ''}`; case 'blackJack': return `${n(e.p)}이 두 번째 카드 ${faceShort(e.card)}를 보여 줬어요${e.extra ? ' → 1장 더!' : ''}`; case 'pedro': return `${n(e.p)}이 버린 카드 ${faceShort(e.card)}를 가져갔어요`; case 'take': { const what = e.equip ? CARD_INFO[e.equip.kind].name : e.card ? `손패 ${faceShort(e.card)}` : '손패 1장'; const how = e.how === 'panic' ? '패닉!으로 ' : e.how === 'gringo' ? '(엘 그링고) ' : '(제시 존스) '; return `${n(e.p)}이 ${how}${n(e.from)}의 ${what}을 가져갔어요`; } case 'discarded': return `${n(e.by)}의 캣 벌루로 ${n(e.p)}의 ${e.fromHand ? '손패 ' : ''}${faceShort(e.card)}를 버렸어요`; case 'limit': return `${n(e.p)}이 카드 ${e.cards.length}장을 버렸어요`; case 'store': return `${n(e.p)}이 잡화점에서 ${faceShort(e.card)}를 골랐어요`; case 'storeOpen': return `잡화점: ${e.cards.map((c) => CARD_INFO[c.kind].name).join(', ')}`; case 'dead': return e.left ? `${n(e.p)}이 나가서 탈락했어요 — ${ROLE_KO(e.role)}` : `${n(e.p)}이 쓰러졌어요 — ${ROLE_KO(e.role)}였어요${e.by ? ` (${n(e.by)})` : ''}`; case 'reward': return `${n(e.p)}이 무법자를 잡아 카드 ${e.n}장을 받아요`; case 'penalty': return `보안관 ${n(e.p)}이 부관을 쏴서 카드를 모두 버려요`; case 'vulture': return `${n(e.p)}이 ${n(e.from)}의 카드 ${e.n}장을 가져갔어요 (벌처 샘)`; case 'character': return `${n(e.p)}이 ${CHAR_INFO[e.character].name}을 골랐어요`; case 'timeout': return `${n(e.p)}의 시간이 다 됐어요`; case 'reshuffle': return `버린 카드 ${e.n}장을 섞어 더미를 채웠어요`; case 'end': return `${TEAM_KO[e.winner]} 승리!`; } } // ---------------------------------------------------------------- board function BangBoard({ view: v, me, players, act, pending }: BoardProps) { const [sel, setSel] = useState(null); const [announce, setAnnounce] = useState(''); const mine = isMyTurn(v, me); const prevMine = useRef(false); const last = v.log[v.log.length - 1]; const turnKey = `${v.current}-${v.phase}-${v.reactions.length}-${v.my?.hand.length ?? 0}-${last?.seq ?? 0}`; useEffect(() => setSel(null), [turnKey]); useEffect(() => { if (mine && !prevMine.current) { haptic(40); sounds.myTurn(); setAnnounce(statusText(v, me, players)); } prevMine.current = mine; }, [mine]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (last && !mine) setAnnounce(logText(last, players)); }, [last?.seq, last?.t]); // eslint-disable-line react-hooks/exhaustive-deps const n = v.players.length; const myIdx = v.players.findIndex((p) => p.id === me); const base = myIdx < 0 ? 0 : myIdx; const selOpt = sel ? v.my?.plays.find((o) => o.cardId === sel.cardId) : undefined; const markOf = (id: string): TargetMark | null => { if (!selOpt?.targets) return null; const t = selOpt.targets.find((x) => x.id === id); if (!t) return null; return { ok: t.ok, reason: t.reason, selected: sel?.target === id }; }; const pick = (id: string) => { if (!sel) return; const t = selOpt?.targets?.find((x) => x.id === id); const only = t?.cards?.length === 1 ? t.cards[0] : undefined; setSel({ cardId: sel.cardId, ...(sel.as ? { as: sel.as } : {}), target: id, ...(only ? { targetCard: only } : {}) }); }; return (
= 7 ? '164px' : '178px' } as CSSProperties}> {v.players.map((p, i) => { const rel = (i - base + n) % n; const ang = Math.PI / 2 + (rel * 2 * Math.PI) / n; return ( ); })}
{v.my && } {v.my && }

{announce}

); } // ---------------------------------------------------------------- side function BangSide({ view: v, me, players }: BoardProps) { return ( <>
뱅! {v.phase === 'draft' ? '· 캐릭터 고르기' : `· ${v.round}라운드`} {v.winner ? ' · 게임 끝' : ''} 카드 더미 {v.deckCount}장 · 버린 카드 {v.discardCount}장 · 반응 {v.options.reactionSeconds}초 · 턴 {v.options.turnSeconds}초
    {v.players.map((p) => (
  1. {p.isTurn ? '▶ ' : ''} {nick(players, p.id)} {p.id === me ? ' (나)' : ''} {p.alive ? : 쓰러짐}
  2. ))}
{v.result && (
{TEAM_KO[v.result.winnerTeam]} 승리 {v.result.ranking.map((id) => { const r = v.result!.players.find((x) => x.id === id)!; return ( {r.won ? '🏆 ' : ''} {nick(players, id)} · {ROLE_INFO[r.role].name} · {CHAR_INFO[r.character].name} {r.alive ? ' · 생존' : ` · ${r.deathOrder}번째로 쓰러짐`} ); })} {v.result.summary}
)} {v.log.length > 0 && (
기록
    {[...v.log].reverse().map((e, i) => (
  1. {logText(e, players)}
  2. ))}
)}
카드 도감
); } // ---------------------------------------------------------------- status function isMyTurn(v: BangView, me: string | null): boolean { if (!me || !v.my || v.winner) return false; return !!v.my.prompt || (v.current === me && v.phase === 'play' && v.reactions.length === 0); } function statusText(v: BangView, me: string | null, players: Record): string { if (v.result) return v.result.summary; const p = v.my?.prompt; if (p) { switch (p.kind) { case 'choose2': return '캐릭터를 골라 주세요'; case 'bang': return p.via === 'gatling' ? '개틀링 공격을 받았어요. 빗나감!을 낼까요?' : '뱅! 공격을 받았어요. 빗나감!을 낼까요?'; case 'indians': return '인디언! 뱅!을 버릴까요?'; case 'duel': return '결투 중이에요. 뱅!을 낼까요?'; case 'dying': return '쓰러지기 직전이에요! 맥주를 마실까요?'; case 'store': return '잡화점에서 카드를 골라 주세요'; case 'kitKeep': return '받을 카드 2장을 골라 주세요'; case 'draw': return '내 차례예요 · 첫 카드를 어디서 받을지 골라 주세요'; case 'discard': return `내 차례예요 · 카드 ${p.count}장을 버려 주세요`; } } if (v.phase === 'draft') return '다른 사람이 캐릭터를 고르는 중이에요'; const meP = v.players.find((x) => x.id === me); if (me && v.current === me && v.reactions.length === 0) return '내 차례예요 · 카드를 내거나 턴을 마쳐 주세요'; if (v.reactions.length) { const who = [...new Set(v.reactions.map((r) => r.who))]; return `${who.map((id) => `${nick(players, id)}님`).join(', ')}의 반응을 기다리는 중이에요`; } if (meP && !meP.alive) return `쓰러졌어요 · ${nick(players, v.current)}님 차례예요`; return `${nick(players, v.current)}님 차례예요`; } // ---------------------------------------------------------------- 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, help }: { label: string; children: ReactNode; help?: string }) { return (
{label} {children} {help && {help}}
); } const onOff: [boolean, string][] = [ [true, '켜기'], [false, '끄기'], ]; function Options({ value: o, onChange }: OptionsProps) { const [more, setMore] = useState(false); const set = (patch: Partial) => onChange({ ...o, ...patch }); return (
set({ characterDraft })} /> set({ turnSeconds })} /> set({ reactionSeconds })} /> {more && ( <> set({ autoDefend })} /> set({ autoBarrel })} /> set({ showDistanceHelper })} /> set({ beginnerHints })} /> set({ drawBeforeDeath })} /> set({ beerAtFullHealth })} /> set({ selfTargetCatBalou })} /> set({ deputyCount7 })} /> )}
); } function optionsSummary(o: BangOptions): string { const parts = [o.characterDraft === 'choose2' ? '캐릭터 고르기' : '캐릭터 무작위', `턴 ${o.turnSeconds}초`, `반응 ${o.reactionSeconds}초`]; if (!o.autoDefend) parts.push('자동 방어 끔'); if (o.drawBeforeDeath || o.beerAtFullHealth || o.selfTargetCatBalou || o.deputyCount7) parts.push('하우스 룰'); return parts.join(' · '); } // ---------------------------------------------------------------- rules function CardTable() { const kinds = Object.keys(CARD_INFO) as CardKind[]; return ( {kinds.map((k) => ( ))}
{CARD_INFO[k].icon} {CARD_INFO[k].name} {BLUE.includes(k) ? ' (장착)' : ''} {CARD_INFO[k].text}
); } function Rules() { return (

4~7명이 비밀 역할을 받아요. 보안관만 정체를 밝히고, 나머지(부관·무법자·배신자)는 숨겨요. 보안관 팀은 무법자와 배신자를 모두 잡으면, 무법자는 보안관을 쓰러뜨리면, 배신자는 마지막에 혼자 남으면 이겨요.

내 차례: 카드 2장을 받고 → 원하는 만큼 카드를 내고 → 손패가 생명보다 많으면 남는 만큼 버려요. 뱅!은 한 턴에 1번, 사거리 안의 사람에게만 쏠 수 있어요. 맞은 사람은 빗나감!으로 피하거나 생명 1을 잃어요.

거리: 살아 있는 사람만 세어 왼쪽·오른쪽 중 가까운 쪽 자리 수예요. 머스탱은 남이 볼 때 +1, 조준경은 내가 볼 때 −1. 무기가 없으면 사거리는 1이에요.

생명이 0이 되면 맥주로 살아날 수 있어요(2명 남으면 안 돼요). 무법자를 쓰러뜨리면 카드 3장을 받고, 보안관이 부관을 쓰러뜨리면 카드를 모두 버려요. 쓰러진 사람의 역할은 공개돼요.

반응 시간이 지나면 막을 카드가 있을 때 자동으로 내요(설정에서 끌 수 있어요). 내 역할은 역할 카드를 꾹 누르고 있을 때만 보여요.

{(Object.keys(CHAR_INFO) as CharacterId[]).map((c) => ( ))}
{CHAR_INFO[c].name} {CHAR_INFO[c].text}
); } const ui: GameUI = { Board: BangBoard, Side: BangSide, statusText, isMyTurn, Options, optionsSummary, Rules, }; export default ui;