Files
joke-app/apps/web/src/games/bang/index.tsx
EJClaw d6496dc1fa M6: 뱅 규칙·화면·테스트
- packages/games/src/bang: 뱅!(4~7명) 규칙 모듈. 카드 80장·캐릭터 16종 데이터, 거리/사거리,
  효과·반응 스택(뱅!/개틀링/인디언!/결투/잡화점/빈사/킷 칼슨/choose2), 다이너마이트·감옥 판정,
  처치 보상·보안관 벌칙·벌처 샘, 승리 판정, 역할·손패·덱 비공개 view, onTimeout/onLeave,
  legalActions·secretsOf. 하우스 룰 옵션 4종.
- 테스트 62개: 문서 §10 시나리오 전부, 시드 1,000개 4~7명 무작위 대국(카드 총수 보존, 생명 범위,
  정보 유출, onTimeout 합법, 결정성) + 엔진 randomPlayout 실행, 재생 결정성.
- apps/web/src/games/bang: 원형 테이블(생명 탄환·장착·거리·역할 배지), 꾹 눌러 보는 내 역할 카드,
  카드 선택 → 대상 강조 → 확인, 반응 시트와 타이머, 기록·카드 도감, PC/휴대폰, 라이트/다크.
- 등록: GAMES, 카탈로그(available), 웹 레지스트리, 아이콘(UPCOMING에서 '뱅' 제거).
- e2e: bang 플랜(PC 방장 + 휴대폰 3명, 반응 25초, 실제 턴 10번: 뱅!·기타 카드·반응·버리기).

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

1286 lines
61 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/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<CardKind, { name: string; icon: string; text: string }> = {
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<CharacterId, { name: string; text: string }> = {
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<Role, { name: string; icon: string; goal: string; tip: string; color: string }> = {
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<Team, string> = { 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<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 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 (
<span className={`bg-card${small ? ' sm' : ''}`} data-blue={BLUE.includes(card.kind)} aria-hidden={!label} {...(label ? { role: 'img', 'aria-label': faceAria(card) } : {})}>
<CardFaceInner card={card} info={info} />
</span>
);
}
function CardFaceInner({ card, info }: { card: CardFace; info: (typeof CARD_INFO)[CardKind] }) {
return (
<>
<span className={`ix ${sr(card)}`}>
{card.rank}
{SUIT_SYMBOL[card.suit]}
</span>
<span className="ic" aria-hidden>
{info.icon}
</span>
<span className="cn">{info.name}</span>
<span className="ct">{info.text}</span>
</>
);
}
function Hp({ life, max }: { life: number; max: number }) {
const n = Math.max(0, life);
return (
<span className="bg-hp" role="img" aria-label={`생명 ${n}/${max}`}>
{Array.from({ length: max }, (_, i) => (
<i key={i} className={i < n ? '' : 'e'} />
))}
</span>
);
}
function RoleBadge({ role, alive }: { role: Role | null; alive: boolean }) {
if (!role) return <span className="bg-role hidden">? 역할 비밀</span>;
const r = ROLE_INFO[role];
return (
<span className="bg-role" style={{ background: r.color }}>
{r.icon} {r.name}
{!alive ? '(공개)' : ''}
</span>
);
}
function Equip({ cards }: { cards: Card[] }) {
if (!cards.length) return null;
return (
<span className="bg-eqs">
{cards.map((c) => (
<span key={c.id} className="bg-eq" data-k={c.kind} title={CARD_INFO[c.kind].text}>
{CARD_INFO[c.kind].icon} {CARD_INFO[c.kind].name}
</span>
))}
</span>
);
}
const REACT_KO: Record<string, string> = {
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<string, PublicUser>, 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<string, PublicUser>;
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 ? (
<TurnTimer deadline={r.deadline} totalMs={v.reactionTotalMs} mine={p.id === me} size={26} />
) : p.isTurn && !v.reactions.length && !v.winner ? (
<TurnTimer deadline={v.turnDeadline} totalMs={v.deadlineTotalMs} mine={p.id === me} size={26} />
) : null;
const cls = `bg-seat${p.isTurn ? ' turn' : ''}${r ? ' react' : ''}${p.id === me ? ' me' : ''}${!p.alive ? ' dead' : ''}`;
const body = (
<>
<span className="bg-row nw">
<Avatar user={u} size={22} />
<span className="nm">
{u.nickname}
{p.id === me ? ' (나)' : ''}
</span>
{timer}
</span>
<span className="bg-row nw rc">
<RoleBadge role={shownRole(p, v, me)} alive={p.alive} />
<span className="bg-char">{p.character ? CHAR_INFO[p.character].name : '캐릭터 고르는 중'}</span>
</span>
{p.alive ? (
<span className="bg-row">
<Hp life={p.life} max={p.maxLife} />
<span className="bg-cnt" aria-hidden>
<b />
{p.handCount}
</span>
{p.distance !== null && v.options.showDistanceHelper && <span className={`bg-dist${p.inRange ? ' in' : ''}`}>거리 {p.distance}</span>}
</span>
) : (
<span className="bg-badge" style={{ background: '#4b5563' }}>
쓰러짐
</span>
)}
<Equip cards={p.inPlay} />
{(p.away || (r && p.id !== me)) && (
<span className="bg-row">
{r && p.id !== me && <span className="bg-badge" style={{ background: '#c2410c' }}>{REACT_KO[r.kind]}</span>}
{p.away && <span className="bg-badge" style={{ background: 'var(--warning)' }}>자리 비움</span>}
</span>
)}
{mark && !mark.ok && mark.reason && <span className="bg-why">{shortReason(mark.reason)}</span>}
</>
);
if (mark?.ok) {
return (
<button
type="button"
className={cls}
style={style}
data-target="ok"
data-player={p.id}
aria-pressed={mark.selected}
aria-label={`${seatLabel(p, players, me, shownRole(p, v, me))}. 대상으로 고르기`}
onClick={() => onPick(p.id)}
>
{body}
</button>
);
}
return (
<div className={cls} style={style} data-target={mark ? 'no' : undefined} data-player={p.id} role="group" aria-label={seatLabel(p, players, me, shownRole(p, v, me)) + (mark?.reason ? `. ${mark.reason}` : '')}>
{body}
</div>
);
}
/** "사거리가 닿지 않아요(거리 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<string, PublicUser> }) {
const top = v.discardTop[v.discardTop.length - 1];
const chk = v.lastCheck;
const PURPOSE: Record<string, string> = { 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 (
<div className="bg-center">
<div className="bg-piles">
<span className="bg-back" role="img" aria-label={`카드 더미 ${v.deckCount}장`}>
{v.deckCount}
</span>
{top ? <CardArt card={top} small label /> : <span className="bg-chip">버린 카드 없음</span>}
</div>
{v.phase !== 'draft' && <span className="bg-chip">{v.round}라운드</span>}
{chk && (
<span className={`bg-chip ${chk.success ? 'ok' : 'bad'}`} aria-live="polite">
{nick(players, chk.player)}님 {PURPOSE[chk.purpose]} 판정: {chk.cards.map((c) => `${SUIT_SYMBOL[c.suit]}${c.rank}`).join(' · ')} → {chk.success ? '성공' : '실패'}
</span>
)}
{waitingFor.length > 0 && <span className="bg-chip">{waitingFor.map((id) => `${nick(players, id)}님`).join(', ')} 기다리는 중…</span>}
{last && <span className="bg-last">{logText(last, players)}</span>}
</div>
);
}
// ---------------------------------------------------------------- my area
function useHold(): [boolean, Record<string, unknown>] {
const [show, setShow] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | 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 (
<span className="stack" style={{ gap: 2 }}>
<button
type="button"
className="bg-rolecard"
data-show={show}
style={show ? { background: r.color, borderColor: r.color } : undefined}
aria-label={show ? `내 역할: ${r.name}. ${r.goal}` : '내 역할 보기. 누르고 있는 동안만 보여요'}
{...handlers}
>
{show ? `${r.icon} ${r.name}` : '🂠 내 역할 (꾹 누르기)'}
</button>
{show && (
<span className="small" style={{ maxWidth: 260 }}>
{r.goal}
{hints ? ` ${r.tip}` : ''}
</span>
)}
</span>
);
}
interface Sel {
cardId: string;
as?: 'bang';
target?: string;
targetCard?: string;
}
function playLabel(o: PlayOption, card: Card, sel: Sel | null, players: Record<string, PublicUser>, 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<HTMLDivElement>) => {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
const btns = Array.from(e.currentTarget.querySelectorAll<HTMLButtonElement>('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 (
<div className="bg-hand" role="group" aria-label={label} onKeyDown={onKey}>
{hand.map((c) => {
const ok = okOf?.(c);
return (
<button
key={c.id}
type="button"
className="bg-card"
data-blue={BLUE.includes(c.kind)}
data-ok={ok}
data-id={c.id}
data-kind={c.kind}
aria-pressed={picked(c)}
aria-label={`${faceAria(c)}${ok === false ? ', 지금 쓸 수 없음' : ''}`}
onClick={() => onTap(c)}
>
<CardFaceInner card={c} info={CARD_INFO[c.kind]} />
</button>
);
})}
{hand.length === 0 && <span className="muted">손에 카드가 없어요.</span>}
</div>
);
}
function MyArea({
v,
act,
pending,
players,
sel,
setSel,
}: {
v: BangView;
act: (a: unknown) => void;
pending: boolean;
players: Record<string, PublicUser>;
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<string[]>([]);
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<Prompt, { kind: 'discard' }>).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 (
<section className="bg-me" aria-label="내 자리">
<div className="bg-mehead">
<RoleCard role={my.role} hints={hints} always={!!v.winner || !meP?.alive} />
<span className="stack" style={{ gap: 2, flex: '1 1 220px', minWidth: 0 }}>
<strong>
{my.character ? CHAR_INFO[my.character].name : '캐릭터를 고르는 중'}
{meP && meP.alive && (
<>
{' '}
<Hp life={meP.life} max={meP.maxLife} />
</>
)}
</strong>
{my.character && <span className="small muted">{CHAR_INFO[my.character].text}</span>}
<span className="small">
사거리 {my.range}
{my.unlimitedBang ? ' · 뱅! 무제한' : v.current === v.me && v.phase === 'play' ? ` · 이번 턴 뱅! ${v.bangsPlayed ? '사용함' : '1번 가능'}` : ''}
</span>
</span>
{meP && <Equip cards={meP.inPlay} />}
</div>
{meP && !meP.alive && !v.winner && <strong>쓰러졌어요. 남은 게임을 지켜봐요.</strong>}
{prompt?.kind === 'draw' && <DrawPanel prompt={prompt} act={act} pending={pending} players={players} />}
{meP?.alive && (
<HandCards
hand={my.hand}
label={`내 카드 ${my.hand.length}장. 좌우 방향키로 고르고 Enter로 눌러요`}
okOf={(c) => (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 && (
<div className="bg-picks" role="group" aria-label="어떤 카드를 고를까요">
{tOpt.cards.map((tc) => {
const eq = targetPlayer.inPlay.find((c) => c.id === tc);
return (
<button key={tc} type="button" className={`btn ${sel?.targetCard === tc ? 'btn-primary' : 'btn-secondary'}`} aria-pressed={sel?.targetCard === tc} onClick={() => setSel({ ...sel!, targetCard: tc })}>
{tc === 'hand' ? `손패에서 무작위 1장 (${targetPlayer.handCount}장)` : `${CARD_INFO[eq!.kind].icon} ${CARD_INFO[eq!.kind].name}`}
</button>
);
})}
</div>
)}
{hint && !modal && (
<p className="bg-hint" aria-live="polite">
{hint}
</p>
)}
<div className="bg-actions">
{discarding && (
<>
<span style={{ flex: '1 1 100%' }}>손패가 생명보다 많아요. 버릴 카드 {(prompt as Extract<Prompt, { kind: 'discard' }>).count}장을 골라 주세요.</span>
<button
type="button"
className="btn btn-primary"
disabled={pending || multi.length !== (prompt as Extract<Prompt, { kind: 'discard' }>).count}
onClick={() => act({ type: 'discard', cardIds: multi })}
>
{multi.length}장 버리기
</button>
</>
)}
{sidMode && (
<>
<button type="button" className="btn btn-primary" disabled={pending || multi.length !== 2} onClick={() => act({ type: 'sidHeal', cardIds: multi })}>
2장 버리고 생명 +1
</button>
<button type="button" className="btn btn-secondary" onClick={() => setSidMode(false)}>
취소
</button>
</>
)}
{playing && !sidMode && (
<>
{sel && (
<button type="button" className="btn btn-primary" disabled={pending || !ready} onClick={confirm}>
{selCard && selOpt ? playLabel(selOpt, selCard, sel, players, targetPlayer) : '내기'}
</button>
)}
{sel && (
<button type="button" className="btn btn-secondary" style={{ flex: '0 1 auto' }} onClick={() => setSel(null)}>
취소
</button>
)}
<button type="button" className="btn btn-secondary" disabled={pending || !my.canEndPlay} onClick={() => act({ type: 'endPlay' })}>
턴 마치기
</button>
</>
)}
{my.canSidHeal && !sidMode && !modal && (
<button type="button" className="btn btn-secondary" disabled={pending} onClick={() => setSidMode(true)}>
시드 능력: 2장 버리고 회복
</button>
)}
</div>
</section>
);
}
function DrawPanel({ prompt, act, pending, players }: { prompt: Extract<Prompt, { kind: 'draw' }>; act: (a: unknown) => void; pending: boolean; players: Record<string, PublicUser> }) {
return (
<div className="stack" style={{ gap: 6 }} role="group" aria-label="카드 받기">
<strong>내 차례예요. 첫 카드를 어디서 받을까요?</strong>
<div className="bg-picks">
<button type="button" className="btn btn-primary" disabled={pending} onClick={() => act({ type: 'draw' })}>
카드 더미에서 2장 받기
</button>
{prompt.pedro && (
<button type="button" className="btn btn-secondary" disabled={pending} onClick={() => act({ type: 'draw', pedroDiscard: true })}>
버린 카드 {faceShort(prompt.pedro)} 가져오기
</button>
)}
{prompt.jesseTargets.map((id) => (
<button key={id} type="button" className="btn btn-secondary" disabled={pending} onClick={() => act({ type: 'draw', jesseFrom: id })}>
{nick(players, id)}님 손패에서 1장
</button>
))}
</div>
</div>
);
}
// ---------------------------------------------------------------- reaction prompt
function PromptModal({ v, act, pending, players }: { v: BangView; act: (a: unknown) => void; pending: boolean; players: Record<string, PublicUser> }) {
const my = v.my!;
const p = my.prompt;
const [picked, setPicked] = useState<string[]>([]);
const [sid, setSid] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const key = p ? `${p.kind}-${v.reactions.length}-${my.hand.length}` : '';
useEffect(() => {
setPicked([]);
setSid(false);
}, [key]);
useEffect(() => {
if (p) ref.current?.querySelector<HTMLButtonElement>('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 ? <TurnTimer deadline={r.deadline} totalMs={v.reactionTotalMs} mine size={44} /> : null;
const n = (id: string) => `${nick(players, id)}님`;
const hand = my.hand;
const sidBox = my.canSidHeal && (
<>
{!sid ? (
<button type="button" className="btn btn-secondary" disabled={pending} onClick={() => setSid(true)}>
시드 능력: 카드 2장 버리고 생명 +1
</button>
) : (
<>
<span className="small">버릴 카드 2장을 골라 주세요.</span>
<HandCards hand={hand} picked={(c) => 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="버릴 카드" />
<button type="button" className="btn btn-primary" disabled={pending || picked.length !== 2} onClick={() => act({ type: 'sidHeal', cardIds: picked })}>
2장 버리고 생명 +1
</button>
</>
)}
</>
);
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 style={{ margin: 0 }}>
{p.needed > 1 ? `빗나감! ${p.needed}장이 필요해요(슬랩 더 킬러). ` : ''}
{can ? '빗나감!을 낼까요?' : p.options.length > 0 ? '빗나감!이 모자라서 막을 수 없어요.' : '막을 카드가 없어요.'}
</p>
<div className="bg-picks">
{can && (
<button type="button" className="btn btn-primary" disabled={pending} onClick={() => act({ type: 'respond', cardIds: cards.map((c) => c.id) })}>
{cards.map((c) => CARD_INFO[c.kind].name).join(' + ')} 내기
</button>
)}
{p.canBarrel && (
<button type="button" className="btn btn-secondary" disabled={pending} onClick={() => act({ type: 'useBarrel' })}>
술통 판정하기
</button>
)}
<button type="button" className={`btn ${can ? 'btn-secondary' : 'btn-danger'}`} disabled={pending} onClick={() => act({ type: 'takeHit' })}>
맞기 (생명 −1)
</button>
</div>
{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 style={{ margin: 0 }}>{p.kind === 'indians' ? '뱅! 1장을 버리거나 생명 1을 잃어요.' : '뱅! 1장을 내면 상대 차례가 돼요. 못 내면 생명 1을 잃어요.'}</p>
<div className="bg-picks">
{card && (
<button type="button" className="btn btn-primary" disabled={pending} onClick={() => act({ type: 'respond', cardIds: [card.id] })}>
{CARD_INFO[card.kind].name} 내기
</button>
)}
<button type="button" className={`btn ${card ? 'btn-secondary' : 'btn-danger'}`} disabled={pending} onClick={() => act({ type: 'takeHit' })}>
{p.kind === 'duel' ? '포기하고 맞기 (생명 −1)' : '맞기 (생명 −1)'}
</button>
</div>
{sidBox}
</>
);
break;
}
case 'dying': {
const can = p.beerAllowed && p.beers.length >= p.needed;
title = '쓰러지기 직전이에요!';
body = (
<>
<p style={{ margin: 0 }}>
{p.beerAllowed ? `맥주 ${p.needed}장을 마시면 살아나요.` : '두 명만 남아서 맥주로는 살아날 수 없어요.'}
{p.beerAllowed && !can ? ' 맥주가 모자라요.' : ''}
</p>
<div className="bg-picks">
{can && (
<button type="button" className="btn btn-primary" disabled={pending} onClick={() => act({ type: 'respond', cardIds: p.beers.slice(0, p.needed) })}>
맥주 {p.needed}장 마시기
</button>
)}
<button type="button" className="btn btn-danger" disabled={pending} onClick={() => act({ type: 'giveUp' })}>
쓰러지기
</button>
</div>
{sidBox}
</>
);
break;
}
case 'store':
title = '잡화점: 카드 1장을 골라 주세요';
body = <HandCards hand={p.revealed} picked={() => false} onTap={(c) => !pending && act({ type: 'storePick', cardId: c.id })} label="잡화점 카드" />;
break;
case 'kitKeep':
title = '3장 중 2장을 골라 주세요';
body = (
<>
<span className="small">고르지 않은 1장은 카드 더미 맨 위로 돌아가요.</span>
<HandCards
hand={p.options}
picked={(c) => 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="킷 칼슨 카드"
/>
<button type="button" className="btn btn-primary" disabled={pending || picked.length !== 2} onClick={() => act({ type: 'kitKeep', keep: picked })}>
이 2장 받기
</button>
</>
);
break;
case 'choose2':
title = '캐릭터를 골라 주세요';
body = (
<div className="bg-chars">
{p.options.map((c) => (
<button key={c} type="button" disabled={pending} onClick={() => act({ type: 'pickCharacter', character: c })}>
<strong>{CHAR_INFO[c].name}</strong>
<span className="small">{CHAR_INFO[c].text}</span>
</button>
))}
</div>
);
break;
}
return (
<div className="bg-modal-back">
<div className="bg-modal" role="dialog" aria-modal="true" aria-label={title} ref={ref}>
<div className="row" style={{ justifyContent: 'space-between', flexWrap: 'nowrap', gap: 8 }}>
<h2>{title}</h2>
{timer}
</div>
{body}
</div>
</div>
);
}
// ---------------------------------------------------------------- log
const PURPOSE_KO: Record<string, string> = { dynamite: '다이너마이트', jail: '감옥', barrel: '술통', jourdonnais: '주르도네' };
const ROLE_KO = (r: Role) => ROLE_INFO[r].name;
function logText(e: LogEntry, players: Record<string, PublicUser>): 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<BangView>) {
const [sel, setSel] = useState<Sel | null>(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 (
<div className="bg">
<style>{CSS}</style>
<div className="bg-table" role="region" aria-label="테이블. 시계 방향 순서" style={{ '--sw': n >= 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 (
<Seat
key={p.id}
p={p}
v={v}
players={players}
me={me}
mark={markOf(p.id)}
onPick={pick}
style={{ '--x': `${50 + 41 * Math.cos(ang)}%`, '--y': `${50 + 37 * Math.sin(ang)}%`, order: rel } as CSSProperties}
/>
);
})}
<Center v={v} players={players} />
</div>
{v.my && <MyArea v={v} act={act} pending={pending} players={players} sel={sel} setSel={setSel} />}
{v.my && <PromptModal v={v} act={act} pending={pending} players={players} />}
<p className="sr-only" aria-live="polite">
{announce}
</p>
</div>
);
}
// ---------------------------------------------------------------- side
function BangSide({ view: v, me, players }: BoardProps<BangView>) {
return (
<>
<style>{CSS}</style>
<div className="card stack" style={{ gap: 6, padding: 12 }}>
<strong>
뱅! {v.phase === 'draft' ? '· 캐릭터 고르기' : `· ${v.round}라운드`}
{v.winner ? ' · 게임 끝' : ''}
</strong>
<span className="small muted">
카드 더미 {v.deckCount}장 · 버린 카드 {v.discardCount}장 · 반응 {v.options.reactionSeconds}초 · 턴 {v.options.turnSeconds}초
</span>
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }}>
{v.players.map((p) => (
<li key={p.id} className="row" style={{ gap: 6, flexWrap: 'nowrap', opacity: p.alive ? 1 : 0.55 }} aria-label={seatLabel(p, players, me, shownRole(p, v, me))}>
<Avatar user={userOf(players, p.id)} size={24} />
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: p.id === me ? 800 : 600 }}>
{p.isTurn ? '▶ ' : ''}
{nick(players, p.id)}
{p.id === me ? ' (나)' : ''}
</span>
<RoleBadge role={shownRole(p, v, me)} alive={p.alive} />
{p.alive ? <Hp life={p.life} max={p.maxLife} /> : <span className="small">쓰러짐</span>}
</li>
))}
</ol>
</div>
{v.result && (
<div className="card stack" style={{ gap: 4, padding: 12 }}>
<strong>{TEAM_KO[v.result.winnerTeam]} 승리</strong>
{v.result.ranking.map((id) => {
const r = v.result!.players.find((x) => x.id === id)!;
return (
<span key={id}>
{r.won ? '🏆 ' : ''}
{nick(players, id)} · {ROLE_INFO[r.role].name} · {CHAR_INFO[r.character].name}
<span className="muted small">{r.alive ? ' · 생존' : ` · ${r.deathOrder}번째로 쓰러짐`}</span>
</span>
);
})}
<span className="small muted">{v.result.summary}</span>
</div>
)}
{v.log.length > 0 && (
<details className="card wide-open" open style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기록</summary>
<ol className="bg-log">
{[...v.log].reverse().map((e, i) => (
<li key={`${e.seq}-${e.t}-${i}`} className="small">
{logText(e, players)}
</li>
))}
</ol>
</details>
)}
<details className="card" style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>카드 도감</summary>
<CardTable />
</details>
</>
);
}
// ---------------------------------------------------------------- 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, PublicUser>): 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<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, help }: { label: string; children: ReactNode; help?: string }) {
return (
<div className="stack" style={{ gap: 6 }}>
<strong>{label}</strong>
{children}
{help && <span className="small muted">{help}</span>}
</div>
);
}
const onOff: [boolean, string][] = [
[true, '켜기'],
[false, '끄기'],
];
function Options({ value: o, onChange }: OptionsProps<BangOptions>) {
const [more, setMore] = useState(false);
const set = (patch: Partial<BangOptions>) => onChange({ ...o, ...patch });
return (
<div className="stack" style={{ gap: 16 }}>
<Field label="캐릭터 정하기">
<Seg label="캐릭터 정하기" value={o.characterDraft} options={[['random', '무작위 1장'], ['choose2', '2장 중 고르기']]} onChange={(characterDraft) => set({ characterDraft })} />
</Field>
<Field label="내 턴 제한 시간">
<Seg label="내 턴 제한 시간" value={o.turnSeconds} options={[[30, '30초'], [60, '60초'], [90, '90초'], [120, '120초']]} onChange={(turnSeconds) => set({ turnSeconds })} />
</Field>
<Field label="반응 제한 시간" help="빗나감!·맥주·결투·잡화점을 고르는 시간이에요.">
<Seg label="반응 제한 시간" value={o.reactionSeconds} options={[[8, '8초'], [15, '15초'], [25, '25초']]} onChange={(reactionSeconds) => set({ reactionSeconds })} />
</Field>
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
</button>
{more && (
<>
<Field label="시간이 지나면 막을 카드 자동으로 내기">
<Seg label="자동 방어" value={o.autoDefend} options={onOff} onChange={(autoDefend) => set({ autoDefend })} />
</Field>
<Field label="술통 판정 자동으로 하기">
<Seg label="술통 자동 판정" value={o.autoBarrel} options={onOff} onChange={(autoBarrel) => set({ autoBarrel })} />
</Field>
<Field label="거리·사거리 표시">
<Seg label="거리 표시" value={o.showDistanceHelper} options={onOff} onChange={(showDistanceHelper) => set({ showDistanceHelper })} />
</Field>
<Field label="초보자 도움말">
<Seg label="초보자 도움말" value={o.beginnerHints} options={onOff} onChange={(beginnerHints) => set({ beginnerHints })} />
</Field>
<Field label="(하우스 룰) 빈사 판정 전에 바트 캐시디·엘 그링고 카드 받기">
<Seg label="빈사 전 카드 받기" value={o.drawBeforeDeath} options={onOff} onChange={(drawBeforeDeath) => set({ drawBeforeDeath })} />
</Field>
<Field label="(하우스 룰) 생명이 가득해도 맥주 내기">
<Seg label="가득 찬 생명에 맥주" value={o.beerAtFullHealth} options={onOff} onChange={(beerAtFullHealth) => set({ beerAtFullHealth })} />
</Field>
<Field label="(하우스 룰) 캣 벌루·패닉!을 내 장착 카드에 쓰기">
<Seg label="내 장착 카드에 쓰기" value={o.selfTargetCatBalou} options={onOff} onChange={(selfTargetCatBalou) => set({ selfTargetCatBalou })} />
</Field>
<Field label="(하우스 룰) 7명일 때 부관 1 + 무법자 4">
<Seg label="7명 역할" value={o.deputyCount7} options={onOff} onChange={(deputyCount7) => set({ deputyCount7 })} />
</Field>
</>
)}
</div>
);
}
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 (
<table className="bg-rules small" style={{ borderCollapse: 'collapse', width: '100%', marginTop: 8 }}>
<tbody>
{kinds.map((k) => (
<tr key={k}>
<td style={{ width: 28, fontSize: '1.2rem' }} aria-hidden>
{CARD_INFO[k].icon}
</td>
<th scope="row" style={{ whiteSpace: 'nowrap' }}>
{CARD_INFO[k].name}
{BLUE.includes(k) ? ' (장착)' : ''}
</th>
<td>{CARD_INFO[k].text}</td>
</tr>
))}
</tbody>
</table>
);
}
function Rules() {
return (
<div className="stack">
<p>
4~7명이 비밀 역할을 받아요. <strong>보안관</strong>만 정체를 밝히고, 나머지(부관·무법자·배신자)는 숨겨요. 보안관 팀은 무법자와 배신자를 모두 잡으면, 무법자는 보안관을 쓰러뜨리면, 배신자는 마지막에 혼자
남으면 이겨요.
</p>
<p>
내 차례: 카드 2장을 받고 → 원하는 만큼 카드를 내고 → 손패가 생명보다 많으면 남는 만큼 버려요. <strong>뱅!</strong>은 한 턴에 1번, 사거리 안의 사람에게만 쏠 수 있어요. 맞은 사람은
<strong> 빗나감!</strong>으로 피하거나 생명 1을 잃어요.
</p>
<p>
<strong>거리</strong>: 살아 있는 사람만 세어 왼쪽·오른쪽 중 가까운 쪽 자리 수예요. 머스탱은 남이 볼 때 +1, 조준경은 내가 볼 때 −1. 무기가 없으면 사거리는 1이에요.
</p>
<p>
생명이 0이 되면 맥주로 살아날 수 있어요(2명 남으면 안 돼요). 무법자를 쓰러뜨리면 카드 3장을 받고, 보안관이 부관을 쓰러뜨리면 카드를 모두 버려요. 쓰러진 사람의 역할은 공개돼요.
</p>
<p className="small muted">반응 시간이 지나면 막을 카드가 있을 때 자동으로 내요(설정에서 끌 수 있어요). 내 역할은 역할 카드를 꾹 누르고 있을 때만 보여요.</p>
<CardTable />
<table className="bg-rules small" style={{ borderCollapse: 'collapse', width: '100%' }}>
<tbody>
{(Object.keys(CHAR_INFO) as CharacterId[]).map((c) => (
<tr key={c}>
<th scope="row" style={{ whiteSpace: 'nowrap' }}>
{CHAR_INFO[c].name}
</th>
<td>{CHAR_INFO[c].text}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
const ui: GameUI<BangView, BangOptions> = {
Board: BangBoard,
Side: BangSide,
statusText,
isMyTurn,
Options,
optionsSummary,
Rules,
};
export default ui;