- 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>
1286 lines
61 KiB
TypeScript
1286 lines
61 KiB
TypeScript
/** 뱅! 화면 (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;
|