- 규칙 모듈 packages/games/src/mafia: 4~12명, 자동/수동 마피아 수, 추가 직업(군인·정치인·영매·연인),
서버 타이머가 phaseTimeout(시스템 전용)으로 페이즈 진행, 인사 낮/토론(±15초, 토론 끝내기)/지목 투표/
최후의 반론/찬반 투표/밤(마피아 공용 대상, 경찰·영매 결과는 밤 마감 시 전달, 의사·군인 순서),
채널(전체/마피아/죽은 사람/연인) 권한을 view에서 필터, 레이트 리밋·고운 말 필터, 자리 비움(2일) 탈락,
나가기 즉시 탈락, 승리 판정·결과(MVP 포함)
- 테스트: 문서 §10 항목 전부, 시드 1,000판 무작위 대국(불변식·누출·onTimeout 합법·결정성),
엔진 randomPlayout, 기록 재생, 실제 Room 통과 테스트(방 채팅 닫힘, 연결별 채팅 필터)
- 화면 apps/web/src/games/mafia: 낮/밤 배너와 큰 남은 시간, 채널 탭 채팅, 꾹 눌러 보는 내 직업,
그리드에서 투표·밤 대상 고르기, 아침 발표·득표 막대, 최종 공개
- 공용 코드(하위 호환): GameDefinition.ownChat, validate의 선택 ctx {now}; Room이 ownChat 게임 진행 중
방 채팅·반응을 거절하고 validate에 서버 시각 전달; 웹 GameUI.ownChat이면 진행 중 방 채팅 패널 숨김
- 등록(GAMES, 카탈로그, 화면 레지스트리, 아이콘)과 E2E 계획(PC 방장 + 휴대폰 3명)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1031 lines
50 KiB
TypeScript
1031 lines
50 KiB
TypeScript
/**
|
||
* 마피아 화면 (docs/games/mafia.md §9).
|
||
*
|
||
* The board carries its own chat with channel tabs (전체 / 마피아 / 죽은 사람 / 연인). The server view
|
||
* only ever contains the channels this viewer may read, so the tabs simply mirror `view.channels`.
|
||
* While the game runs the room chat panel and quick reactions are closed (`ownChat`), so night talk
|
||
* cannot leak through the room chat; after the game the room chat opens again.
|
||
*/
|
||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
|
||
import type { Channel, ChatView, MafiaOptions, MafiaView, PlayerView, PrivateResult, PublicEvent, Role, Team } from '@bg/games/mafia';
|
||
import type { PublicUser } from '@bg/shared/lite';
|
||
import { Avatar } from '../../components/ui';
|
||
import { useServerNow } from '../../components/TurnTimer';
|
||
import { haptic, sounds } from '../../lib/feedback';
|
||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||
|
||
// ---------------------------------------------------------------- names (original names only, docs §12)
|
||
|
||
const ROLE_INFO: Record<Role, { name: string; icon: string; goal: string; color: string }> = {
|
||
mafia: { name: '마피아', icon: '🕶️', goal: '밤마다 시민 1명을 골라 탈락시켜요. 마피아 수가 시민 수와 같아지면 이겨요.', color: '#991b1b' },
|
||
police: { name: '경찰', icon: '🔎', goal: '밤마다 1명을 조사해 마피아인지 알아내요. 마피아를 모두 찾으면 시민 팀이 이겨요.', color: '#1d4ed8' },
|
||
doctor: { name: '의사', icon: '🩺', goal: '밤마다 1명을 지켜요. 마피아가 노린 사람이면 살아나요.', color: '#047857' },
|
||
citizen: { name: '시민', icon: '🙂', goal: '토론과 투표로 숨은 마피아를 찾아요.', color: '#4b5563' },
|
||
soldier: { name: '군인', icon: '🪖', goal: '마피아의 공격을 한 번 버텨요. 토론과 투표로 마피아를 찾아요.', color: '#4d7c0f' },
|
||
politician: { name: '정치인', icon: '🎤', goal: '내 표는 2표예요. 투표로는 탈락하지 않아요.', color: '#7c3aed' },
|
||
medium: { name: '영매', icon: '🔮', goal: '밤에 죽은 사람과 이야기하고, 탈락한 사람 1명의 직업을 알 수 있어요.', color: '#6d28d9' },
|
||
lover: { name: '연인', icon: '💞', goal: '연인 2명은 서로를 알아요. 밤에 둘만 이야기할 수 있어요.', color: '#be185d' },
|
||
};
|
||
const ROLE_ORDER: Role[] = ['mafia', 'police', 'doctor', 'soldier', 'politician', 'medium', 'lover', 'citizen'];
|
||
const TEAM_KO: Record<Team, string> = { town: '시민 팀', mafia: '마피아 팀' };
|
||
const CHANNEL_KO: Record<Channel, string> = { day: '전체', mafia: '마피아', dead: '죽은 사람', lovers: '연인' };
|
||
const CHANNEL_ORDER: Channel[] = ['day', 'mafia', 'dead', 'lovers'];
|
||
const PHASE_KO: Record<MafiaView['phase']['kind'], string> = {
|
||
roleReveal: '직업 확인',
|
||
greeting: '인사 시간',
|
||
discussion: '낮 토론',
|
||
vote: '지목 투표',
|
||
defense: '최후의 반론',
|
||
confirm: '찬반 투표',
|
||
night: '밤',
|
||
ended: '게임 끝',
|
||
};
|
||
|
||
const PHASE_EVENT: Record<Exclude<MafiaView['phase']['kind'], 'ended'>, string> = {
|
||
roleReveal: '직업 확인 시간이에요',
|
||
greeting: '인사 시간이에요',
|
||
discussion: '낮 토론 시간이에요',
|
||
vote: '지목 투표 시간이에요',
|
||
defense: '최후의 반론 시간이에요',
|
||
confirm: '찬반 투표 시간이에요',
|
||
night: '밤이 됐어요',
|
||
};
|
||
|
||
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 isNight = (v: MafiaView) => v.phase.kind === 'night' || v.phase.kind === 'roleReveal';
|
||
|
||
// ---------------------------------------------------------------- styles
|
||
|
||
const CSS = `
|
||
.mf { width: 100%; height: 100%; min-height: 0; display: flex; flex-direction: column; gap: 10px; }
|
||
.mf-banner { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; border-radius: 16px; padding: 10px 14px; box-shadow: var(--shadow);
|
||
background: linear-gradient(135deg, #fff7d6, #fde68a); color: #422006; border: 2px solid #f59e0b; transition: background .6s ease, color .6s ease; }
|
||
.mf-banner[data-time='night'] { background: linear-gradient(135deg, #111827, #312e81); color: #eef2ff; border-color: #4338ca; }
|
||
.mf-banner[data-time='end'] { background: linear-gradient(135deg, #ecfdf5, #bbf7d0); color: #052e16; border-color: #16a34a; }
|
||
.mf-ph { display: flex; flex-direction: column; gap: 0; min-width: 0; flex: 1 1 200px; }
|
||
.mf-ph .d { font-size: .85rem; font-weight: 700; opacity: .85; }
|
||
.mf-ph strong { font-size: 1.45rem; line-height: 1.2; }
|
||
.mf-ph .s { font-size: .9rem; font-weight: 600; }
|
||
.mf-clock { font-size: 2.4rem; font-weight: 900; font-variant-numeric: tabular-nums; line-height: 1; min-width: 3.2ch; text-align: center; }
|
||
.mf-clock[data-warn='true'] { color: #dc2626; }
|
||
.mf-banner[data-time='night'] .mf-clock[data-warn='true'] { color: #fca5a5; }
|
||
.mf-rolecard { min-height: 52px; min-width: 128px; border-radius: 12px; border: 2px dashed currentColor; background: rgba(255,255,255,.18); color: inherit;
|
||
font: inherit; font-weight: 800; padding: 4px 12px; cursor: pointer; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; touch-action: none; }
|
||
.mf-rolecard[data-show='true'] { color: #fff; border-style: solid; }
|
||
.mf-rolecard:focus-visible, .mf-tile:focus-visible, .mf-tab:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||
.mf-rolehint { font-size: .82rem; max-width: 300px; font-weight: 600; }
|
||
.mf-news { border-radius: 16px; padding: 10px 14px; background: var(--surface); color: var(--text); box-shadow: var(--shadow); border: 3px solid var(--focus);
|
||
display: flex; flex-direction: column; gap: 6px; animation: mf-pop .35s ease-out; }
|
||
.mf-news h2 { margin: 0; font-size: 1.05rem; }
|
||
.mf-news .big { font-size: 1.15rem; font-weight: 800; }
|
||
.mf-bars { display: grid; gap: 4px; }
|
||
.mf-bar { display: grid; grid-template-columns: minmax(70px, 140px) 1fr 2.2em; gap: 6px; align-items: center; font-size: .88rem; font-weight: 700; }
|
||
.mf-bar .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.mf-bar .tr { height: 14px; border-radius: 7px; background: var(--surface-2); overflow: hidden; }
|
||
.mf-bar .tr i { display: block; height: 100%; background: #f97316; border-radius: 7px; animation: mf-grow .7s ease-out; transform-origin: left; }
|
||
.mf-bar[data-top='true'] .tr i { background: #dc2626; }
|
||
.mf-grid { flex: 1; min-height: 0; display: grid; gap: 10px; grid-template-columns: 236px minmax(0, 1fr) 280px; grid-template-areas: 'players chat actions'; }
|
||
.mf-col { min-height: 0; overflow: auto; display: flex; flex-direction: column; gap: 8px; }
|
||
.mf-players { grid-area: players; }
|
||
.mf-chatwrap { grid-area: chat; }
|
||
.mf-actions { grid-area: actions; }
|
||
.mf-box { background: var(--surface); color: var(--text); border-radius: 16px; box-shadow: var(--shadow); padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; }
|
||
.mf-box h2 { margin: 0; font-size: 1rem; }
|
||
.mf-tiles { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; }
|
||
.mf-tile { position: relative; display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 6px 4px; border-radius: 12px; min-height: 76px;
|
||
background: var(--surface-2); color: var(--text); border: 3px solid transparent; font: inherit; text-align: center; }
|
||
button.mf-tile { cursor: pointer; }
|
||
.mf-tile .nm { font-weight: 800; font-size: .86rem; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.mf-tile.me { border-color: var(--primary); }
|
||
.mf-tile.dead { filter: grayscale(1); opacity: .6; }
|
||
.mf-tile.accused { border-color: #dc2626; box-shadow: 0 0 0 3px rgba(220,38,38,.35); }
|
||
.mf-tile[data-target='ok'] { border-color: var(--success); box-shadow: 0 0 0 3px rgba(21,128,61,.35); }
|
||
.mf-tile[data-target='ok'][aria-pressed='true'] { background: #15803d; color: #fff; border-color: #15803d; box-shadow: 0 0 0 4px rgba(21,128,61,.55); }
|
||
.mf-tile[data-target='no'] { opacity: .5; }
|
||
.mf-tag { display: inline-block; border-radius: 999px; padding: 0 7px; font-size: .72rem; font-weight: 800; line-height: 1.6; color: #fff; background: #4b5563; white-space: nowrap; }
|
||
.mf-tag.unk { background: transparent; color: var(--text-sub); border: 1px dashed var(--border); }
|
||
.mf-tag.mark { background: #dc2626; }
|
||
.mf-chat { flex: 1; min-height: 0; padding: 0; overflow: hidden; gap: 0; }
|
||
.mf-tabs { display: flex; gap: 4px; padding: 8px 8px 0; flex-wrap: wrap; border-bottom: 1px solid var(--border); }
|
||
.mf-tab { position: relative; min-height: 44px; padding: 6px 12px; border: 0; border-radius: 10px 10px 0 0; background: transparent; color: var(--text-sub); font: inherit; font-weight: 700; cursor: pointer; }
|
||
.mf-tab[aria-selected='true'] { background: var(--primary-soft); color: var(--text); box-shadow: inset 0 -3px 0 var(--primary); }
|
||
.mf-tab[data-ch='mafia'][aria-selected='true'] { background: #fee2e2; color: #7f1d1d; box-shadow: inset 0 -3px 0 #dc2626; }
|
||
.mf-tab[data-ch='dead'][aria-selected='true'] { background: #e5e7eb; color: #111827; box-shadow: inset 0 -3px 0 #4b5563; }
|
||
.mf-tab[data-ch='lovers'][aria-selected='true'] { background: #fce7f3; color: #831843; box-shadow: inset 0 -3px 0 #db2777; }
|
||
.mf-tab .n { display: inline-block; margin-left: 4px; min-width: 1.4em; border-radius: 999px; background: #dc2626; color: #fff; font-size: .72rem; padding: 0 5px; }
|
||
.mf-msgs { flex: 1; min-height: 160px; overflow-y: auto; padding: 8px 10px; display: flex; flex-direction: column; gap: 5px; }
|
||
.mf-msg { line-height: 1.4; overflow-wrap: anywhere; }
|
||
.mf-msg strong { margin-right: 6px; }
|
||
.mf-msg.mine strong { color: var(--primary); }
|
||
.mf-msg.sys { font-size: .86rem; font-weight: 700; color: var(--text-sub); background: var(--surface-2); border-radius: 8px; padding: 3px 8px; }
|
||
.mf-msg.sys.hot { color: var(--danger); }
|
||
.mf-form { display: flex; gap: 6px; padding: 8px; border-top: 1px solid var(--border); flex-wrap: nowrap; }
|
||
.mf-form .input { flex: 1; min-height: 44px; min-width: 0; }
|
||
.mf-why { padding: 8px 10px; border-top: 1px solid var(--border); font-size: .88rem; color: var(--text-sub); font-weight: 600; }
|
||
.mf-big { display: flex; flex-wrap: wrap; gap: 8px; }
|
||
.mf-big .btn { flex: 1 1 auto; min-height: 52px; }
|
||
.mf-prompt { margin: 0; font-weight: 800; font-size: 1.02rem; }
|
||
.mf-results { margin: 0; padding: 0; list-style: none; display: grid; gap: 4px; font-size: .9rem; }
|
||
.mf-results li { background: var(--surface-2); border-radius: 8px; padding: 4px 8px; font-weight: 700; }
|
||
.mf-final { border: 3px solid #16a34a; }
|
||
.mf-final table { border-collapse: collapse; width: 100%; font-size: .9rem; }
|
||
.mf-final td, .mf-final th { padding: 4px 6px; text-align: left; border-bottom: 1px solid var(--border); }
|
||
.mf-log { list-style: none; margin: 6px 0 0; padding: 0; display: grid; gap: 3px; max-height: 320px; overflow: auto; }
|
||
.mf-rules td, .mf-rules th { padding: 4px 6px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--border); }
|
||
@keyframes mf-pop { from { transform: scale(.96); opacity: 0; } to { transform: none; opacity: 1; } }
|
||
@keyframes mf-grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }
|
||
@media (max-width: 1099px) {
|
||
.mf { height: auto; }
|
||
.mf-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-areas: 'actions players' 'chat chat'; }
|
||
.mf-col { overflow: visible; }
|
||
.mf-msgs { max-height: 420px; }
|
||
}
|
||
@media (max-width: 719px) {
|
||
.mf-grid { grid-template-columns: minmax(0, 1fr); grid-template-areas: 'actions' 'players' 'chat'; }
|
||
.mf-banner { padding: 8px 10px; gap: 8px; }
|
||
.mf-ph strong { font-size: 1.2rem; }
|
||
.mf-clock { font-size: 2rem; }
|
||
.mf-rolecard { min-width: 104px; min-height: 48px; font-size: .9rem; }
|
||
.mf-tiles { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||
.mf-tile { min-height: 70px; }
|
||
.mf-msgs { min-height: 200px; max-height: 340px; }
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.mf-banner { transition: none; }
|
||
.mf-news, .mf-bar .tr i { animation: none; }
|
||
}
|
||
`;
|
||
|
||
// ---------------------------------------------------------------- small parts
|
||
|
||
function Clock({ endsAt }: { endsAt: number | null }) {
|
||
const now = useServerNow(500);
|
||
if (endsAt === null) return null;
|
||
const sec = Math.max(0, Math.ceil((endsAt - now) / 1000));
|
||
const text = sec >= 60 ? `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}` : `${sec}`;
|
||
return (
|
||
<span className="mf-clock" data-warn={sec <= 5} role="timer" aria-label={`남은 시간 ${sec}초`}>
|
||
{text}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
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);
|
||
},
|
||
},
|
||
];
|
||
}
|
||
|
||
/** "내 직업" — only visible while held (screen sharing / people next to you, docs §9). */
|
||
function RoleCard({ role, mates, players, always }: { role: Role; mates: string[]; players: Record<string, PublicUser>; always: boolean }) {
|
||
const [held, handlers] = useHold();
|
||
const show = held || always;
|
||
const r = ROLE_INFO[role];
|
||
const mateText = mates.length ? ` ${role === 'mafia' ? '동료 마피아' : '내 연인'}: ${mates.map((m) => `${nick(players, m)}님`).join(', ')}.` : '';
|
||
return (
|
||
<span className="stack" style={{ gap: 2, alignItems: 'flex-end' }}>
|
||
<button
|
||
type="button"
|
||
className="mf-rolecard"
|
||
data-show={show}
|
||
style={show ? { background: r.color, borderColor: r.color } : undefined}
|
||
aria-label={show ? `내 직업: ${r.name}. ${r.goal}${mateText}` : '내 직업 보기. 누르고 있는 동안만 보여요'}
|
||
{...handlers}
|
||
>
|
||
{show ? `${r.icon} ${r.name}` : '🂠 내 직업 (꾹 누르기)'}
|
||
</button>
|
||
{show && (
|
||
<span className="mf-rolehint">
|
||
{r.goal}
|
||
{mateText}
|
||
</span>
|
||
)}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function RoleTag({ p, meId }: { p: PlayerView; meId: string | null }) {
|
||
// My own role stays on the hold-to-see card while I am alive (docs §9).
|
||
if (p.role && !(p.id === meId && p.alive)) {
|
||
const r = ROLE_INFO[p.role];
|
||
return (
|
||
<span className="mf-tag" style={{ background: r.color }}>
|
||
{r.icon} {r.name}
|
||
</span>
|
||
);
|
||
}
|
||
if (p.team && !p.alive) return <span className="mf-tag" style={{ background: p.team === 'mafia' ? '#991b1b' : '#1d4ed8' }}>{p.team === 'mafia' ? '마피아였어요' : '시민 팀'}</span>;
|
||
return null;
|
||
}
|
||
|
||
function eventText(e: PublicEvent, players: Record<string, PublicUser>): string {
|
||
const n = (id: string) => `${nick(players, id)}님`;
|
||
switch (e.t) {
|
||
case 'phase':
|
||
return `${e.day}일차 ${PHASE_EVENT[e.kind]}`;
|
||
case 'adjust':
|
||
return `${n(e.p)}이 토론 시간을 15초 ${e.delta > 0 ? '늘렸어요' : '줄였어요'}`;
|
||
case 'vote':
|
||
if (e.accused) return `지목 투표: ${n(e.accused)}이 ${e.tally[e.accused]}표로 지목됐어요`;
|
||
return e.tie ? '지목 투표가 동률이라 아무도 지목되지 않았어요' : '모두 기권해서 아무도 지목되지 않았어요';
|
||
case 'confirm': {
|
||
const head = `찬성 ${e.yes} · 반대 ${e.no} — `;
|
||
if (e.outcome === 'executed') return `${head}${n(e.accused)}이 투표로 탈락했어요`;
|
||
if (e.outcome === 'politician') return `${head}${n(e.accused)}은 정치인이라 탈락하지 않아요`;
|
||
return `${head}${n(e.accused)}이 살아남았어요`;
|
||
}
|
||
case 'death': {
|
||
const reveal = e.role ? ` (직업: ${ROLE_INFO[e.role].name})` : e.team ? ` (${e.team === 'mafia' ? '마피아였어요' : '마피아가 아니었어요'})` : '';
|
||
if (e.by === 'mafia') return `밤사이 ${n(e.p)}이 마피아에게 당해 탈락했어요${reveal}`;
|
||
if (e.by === 'disconnect') return `${n(e.p)}이 자리를 비워 탈락했어요${reveal}`;
|
||
return `${n(e.p)}이 탈락했어요${reveal}`;
|
||
}
|
||
case 'healed':
|
||
return '의사의 치료로 누군가 살아났어요';
|
||
case 'soldier':
|
||
return `군인 ${n(e.p)}이 마피아의 공격을 버텼어요`;
|
||
case 'quiet':
|
||
return '조용한 밤이었어요. 아무도 탈락하지 않았어요';
|
||
case 'end':
|
||
return `${TEAM_KO[e.winner]} 승리!`;
|
||
}
|
||
}
|
||
|
||
/** Announcements since the previous phase change (morning news, vote result, execution). */
|
||
function latestNews(events: PublicEvent[]): PublicEvent[] {
|
||
let i = events.length - 1;
|
||
while (i >= 0 && (events[i]!.t === 'phase' || events[i]!.t === 'adjust')) i--;
|
||
const out: PublicEvent[] = [];
|
||
for (; i >= 0 && events[i]!.t !== 'phase'; i--) if (events[i]!.t !== 'adjust') out.unshift(events[i]!);
|
||
return out;
|
||
}
|
||
|
||
function News({ v, players }: { v: MafiaView; players: Record<string, PublicUser> }) {
|
||
const news = latestNews(v.events);
|
||
if (!news.length || v.final) return null;
|
||
const night = news.some((e) => e.t === 'healed' || e.t === 'quiet' || e.t === 'soldier' || (e.t === 'death' && e.by === 'mafia'));
|
||
const title = night ? '아침 발표' : news.some((e) => e.t === 'confirm') ? '찬반 투표 결과' : news.some((e) => e.t === 'vote') ? '지목 투표 결과' : '알림';
|
||
return (
|
||
<section className="mf-news" key={news[0]!.n} aria-label={title} role="status">
|
||
<h2>📢 {title}</h2>
|
||
{news.map((e) =>
|
||
e.t === 'vote' ? (
|
||
<VoteBars key={e.n} e={e} players={players} />
|
||
) : (
|
||
<span key={e.n} className="big">
|
||
{eventText(e, players)}
|
||
</span>
|
||
),
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function VoteBars({ e, players }: { e: Extract<PublicEvent, { t: 'vote' }>; players: Record<string, PublicUser> }) {
|
||
const rows = Object.entries(e.tally).sort((a, b) => b[1] - a[1]);
|
||
const max = Math.max(1, ...rows.map((r) => r[1]));
|
||
return (
|
||
<>
|
||
<span className="big">{eventText(e, players)}</span>
|
||
{rows.length > 0 && (
|
||
<div className="mf-bars" role="list" aria-label="득표">
|
||
{rows.map(([id, n]) => (
|
||
<div key={id} className="mf-bar" data-top={id === e.accused} role="listitem" aria-label={`${nick(players, id)} ${n}표`}>
|
||
<span className="nm">{nick(players, id)}</span>
|
||
<span className="tr" aria-hidden>
|
||
<i style={{ width: `${(n / max) * 100}%` }} />
|
||
</span>
|
||
<span>{n}표</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{e.abstain > 0 && <span className="small">기권 {e.abstain}표</span>}
|
||
{e.votes && (
|
||
<span className="small muted">
|
||
{Object.entries(e.votes)
|
||
.map(([from, to]) => `${nick(players, from)} → ${to === 'abstain' ? '기권' : nick(players, to)}`)
|
||
.join(' · ')}
|
||
</span>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- players
|
||
|
||
type Pick = { ids: string[]; chosen: string | null; onPick: (id: string) => void } | null;
|
||
|
||
function Players({ v, players, me, pick, pending }: { v: MafiaView; players: Record<string, PublicUser>; me: string | null; pick: Pick; pending: boolean }) {
|
||
const mafiaChoice = v.my?.night?.action === 'mafiaTarget' ? v.my.night.choice : null;
|
||
return (
|
||
<section className="mf-box" aria-label={`사람들. 살아 있는 사람 ${v.aliveCount}명`}>
|
||
<h2>
|
||
사람들 <span className="small muted">살아 있는 사람 {v.aliveCount}명</span>
|
||
</h2>
|
||
<div className="mf-tiles">
|
||
{v.players.map((p) => {
|
||
const u = userOf(players, p.id);
|
||
const cls = `mf-tile${p.id === me ? ' me' : ''}${!p.alive ? ' dead' : ''}${p.id === v.phase.accused ? ' accused' : ''}`;
|
||
const can = pick?.ids.includes(p.id) ?? false;
|
||
const body = (
|
||
<>
|
||
<Avatar user={u} size={30} />
|
||
<span className="nm">
|
||
{u.nickname}
|
||
{p.id === me ? ' (나)' : ''}
|
||
</span>
|
||
<RoleTag p={p} meId={me} />
|
||
{!p.alive && <span className="mf-tag">탈락 · {p.diedDay}일차</span>}
|
||
{p.id === v.phase.accused && <span className="mf-tag mark">지목됨</span>}
|
||
{mafiaChoice === p.id && !pick && <span className="mf-tag mark">🎯 오늘 밤 대상</span>}
|
||
</>
|
||
);
|
||
const label = [
|
||
`${u.nickname}${p.id === me ? '(나)' : ''}`,
|
||
p.alive ? '살아 있음' : `${p.diedDay}일차에 탈락`,
|
||
p.role && p.id !== me ? ROLE_INFO[p.role].name : '',
|
||
p.id === v.phase.accused ? '지목됨' : '',
|
||
]
|
||
.filter(Boolean)
|
||
.join(', ');
|
||
if (pick && can) {
|
||
const chosen = pick.chosen === p.id;
|
||
return (
|
||
<button key={p.id} type="button" className={cls} data-target="ok" data-player={p.id} aria-pressed={chosen} disabled={pending} aria-label={`${label}. ${chosen ? '고른 사람' : '고르기'}`} onClick={() => pick.onPick(p.id)}>
|
||
{body}
|
||
</button>
|
||
);
|
||
}
|
||
return (
|
||
<div key={p.id} className={cls} data-target={pick ? 'no' : undefined} data-player={p.id} role="group" aria-label={label}>
|
||
{body}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- chat
|
||
|
||
type Line = ({ k: 'chat'; m: ChatView } | { k: 'sys'; e: PublicEvent }) & { at: number };
|
||
|
||
function ChatBox({ v, players, me, act }: { v: MafiaView; players: Record<string, PublicUser>; me: string | null; act: (a: unknown) => void }) {
|
||
const tabs = useMemo(() => {
|
||
const set = new Set<Channel>(['day', ...v.channels.map((c) => c.channel), ...v.chat.map((m) => m.channel)]);
|
||
return CHANNEL_ORDER.filter((c) => set.has(c));
|
||
}, [v.channels, v.chat]);
|
||
const [tab, setTab] = useState<Channel>('day');
|
||
const [text, setText] = useState('');
|
||
const [seen, setSeen] = useState<Record<string, number>>({});
|
||
const listRef = useRef<HTMLDivElement>(null);
|
||
const cur: Channel = tabs.includes(tab) ? tab : 'day';
|
||
// Day → 전체; night → the first private channel I may write in (only on my own screen).
|
||
const phaseKey = `${v.day}-${v.phase.kind}`;
|
||
useEffect(() => {
|
||
if (v.phase.kind === 'night') {
|
||
const priv = v.channels.find((c) => c.channel !== 'day' && c.canWrite);
|
||
if (priv) setTab(priv.channel);
|
||
} else if (v.phase.kind !== 'ended') setTab('day');
|
||
}, [phaseKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
const count = (c: Channel) => v.chat.filter((m) => m.channel === c).length;
|
||
const lines: Line[] = useMemo(() => {
|
||
const msgs: Line[] = v.chat.filter((m) => m.channel === cur).map((m) => ({ k: 'chat' as const, m, at: m.at }));
|
||
if (cur !== 'day') return msgs;
|
||
// Public announcements (the system channel) are shown inside 전체.
|
||
const sys: Line[] = v.events.map((e) => ({ k: 'sys' as const, e, at: e.at }));
|
||
return [...sys, ...msgs].sort((a, b) => a.at - b.at || (a.k === 'sys' ? -1 : 1));
|
||
}, [v.chat, v.events, cur]);
|
||
useEffect(() => {
|
||
setSeen((s) => ({ ...s, [cur]: count(cur) }));
|
||
listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
|
||
}, [cur, v.chat.length, v.events.length]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
const ch = v.channels.find((c) => c.channel === cur);
|
||
const canWrite = !!ch?.canWrite;
|
||
const why = ch ? ch.why : me ? '이 채팅은 읽기만 할 수 있어요.' : '관전 중에는 채팅을 쓸 수 없어요.';
|
||
const onTabKey = (e: KeyboardEvent<HTMLDivElement>) => {
|
||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
||
e.preventDefault();
|
||
const i = tabs.indexOf(cur);
|
||
const next = tabs[(i + (e.key === 'ArrowRight' ? 1 : tabs.length - 1)) % tabs.length]!;
|
||
setTab(next);
|
||
e.currentTarget.querySelector<HTMLButtonElement>(`[data-ch='${next}']`)?.focus();
|
||
};
|
||
return (
|
||
<section className="mf-box mf-chat" aria-label="게임 채팅">
|
||
<div className="mf-tabs" role="tablist" aria-label="채팅방" onKeyDown={onTabKey}>
|
||
{tabs.map((c) => {
|
||
const unread = c === cur ? 0 : Math.max(0, count(c) - (seen[c] ?? 0));
|
||
return (
|
||
<button
|
||
key={c}
|
||
type="button"
|
||
role="tab"
|
||
className="mf-tab"
|
||
data-ch={c}
|
||
aria-selected={c === cur}
|
||
tabIndex={c === cur ? 0 : -1}
|
||
aria-label={`${CHANNEL_KO[c]} 채팅${unread ? `, 새 메시지 ${unread}개` : ''}`}
|
||
onClick={() => setTab(c)}
|
||
>
|
||
{CHANNEL_KO[c]}
|
||
{unread > 0 && <span className="n">{unread}</span>}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<div ref={listRef} className="mf-msgs" role="tabpanel" aria-label={`${CHANNEL_KO[cur]} 채팅 내용`} aria-live="polite" data-channel={cur}>
|
||
{lines.length === 0 && <p className="muted small" style={{ margin: 0 }}>아직 메시지가 없어요.</p>}
|
||
{lines.map((l) =>
|
||
l.k === 'sys' ? (
|
||
<div key={`e${l.e.n}`} className={`mf-msg sys${l.e.t === 'death' || l.e.t === 'end' ? ' hot' : ''}`}>
|
||
📢 {eventText(l.e, players)}
|
||
</div>
|
||
) : (
|
||
<div key={l.m.key} className={`mf-msg${l.m.from === me ? ' mine' : ''}`}>
|
||
<strong>{nick(players, l.m.from)}</strong>
|
||
{l.m.text}
|
||
</div>
|
||
),
|
||
)}
|
||
</div>
|
||
{canWrite ? (
|
||
<form
|
||
className="mf-form"
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
const t = text.trim();
|
||
if (!t) return;
|
||
act({ type: 'chat', channel: cur, text: t.slice(0, 200) });
|
||
setText('');
|
||
}}
|
||
>
|
||
<input
|
||
className="input"
|
||
value={text}
|
||
onChange={(e) => setText(e.target.value)}
|
||
maxLength={200}
|
||
placeholder={`${CHANNEL_KO[cur]} 채팅에 쓰기`}
|
||
aria-label={`${CHANNEL_KO[cur]} 채팅 메시지`}
|
||
enterKeyHint="send"
|
||
/>
|
||
<button className="btn btn-primary btn-sm">보내기</button>
|
||
</form>
|
||
) : (
|
||
<p className="mf-why">{why}</p>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- actions
|
||
|
||
function resultText(r: PrivateResult, players: Record<string, PublicUser>): string {
|
||
const who = `${nick(players, r.target)}님`;
|
||
if (r.kind === 'peek') return `${r.day}일차 밤 · ${who}의 직업은 ${ROLE_INFO[r.result as Role].name}이에요`;
|
||
if (r.result === 'notMafia') return `${r.day}일차 밤 조사 · ${who}은 마피아가 아니에요`;
|
||
if (r.result === 'mafia') return `${r.day}일차 밤 조사 · ${who}은 마피아예요!`;
|
||
return `${r.day}일차 밤 조사 · ${who}의 직업은 ${ROLE_INFO[r.result].name}이에요`;
|
||
}
|
||
|
||
function Actions({ v, players, me, act, pending }: { v: MafiaView; players: Record<string, PublicUser>; me: string | null; act: (a: unknown) => void; pending: boolean }) {
|
||
const my = v.my;
|
||
const ph = v.phase;
|
||
const n = (id: string) => `${nick(players, id)}님`;
|
||
let body: ReactNode = null;
|
||
if (!my) {
|
||
body = <p className="mf-prompt">관전 중이에요. 낮 채팅과 발표만 볼 수 있어요.</p>;
|
||
} else if (ph.kind === 'ended') {
|
||
body = <p className="mf-prompt">게임이 끝났어요. 모든 직업이 공개됐어요.</p>;
|
||
} else if (!my.alive) {
|
||
body = (
|
||
<>
|
||
<p className="mf-prompt">탈락했어요.</p>
|
||
<span className="small">죽은 사람 채팅에서만 이야기할 수 있어요. 살아 있는 사람에게 정보를 알려 주지 말아 주세요.</span>
|
||
</>
|
||
);
|
||
} else if (ph.kind === 'roleReveal') {
|
||
body = (
|
||
<>
|
||
<p className="mf-prompt">위의 "내 직업" 카드를 꾹 눌러 직업을 확인해 주세요.</p>
|
||
<span className="small">곧 {v.options.firstPhase === 'day' ? '인사 시간' : '첫 밤'}이 시작돼요.</span>
|
||
</>
|
||
);
|
||
} else if (ph.kind === 'greeting' || ph.kind === 'discussion') {
|
||
body = (
|
||
<>
|
||
<p className="mf-prompt">{ph.kind === 'greeting' ? '인사 시간이에요. 오늘은 투표가 없어요.' : '토론 시간이에요. 마피아가 누구일까요?'}</p>
|
||
{ph.kind === 'discussion' && v.options.timeAdjust && (
|
||
<div className="mf-big" role="group" aria-label="토론 시간 바꾸기">
|
||
<button type="button" className="btn btn-secondary" disabled={pending || !my.canAdjust} onClick={() => act({ type: 'adjustTime', delta: 15 })} aria-label="토론 시간 15초 늘리기">
|
||
+15초
|
||
</button>
|
||
<button type="button" className="btn btn-secondary" disabled={pending || !my.canAdjust} onClick={() => act({ type: 'adjustTime', delta: -15 })} aria-label="토론 시간 15초 줄이기">
|
||
−15초
|
||
</button>
|
||
</div>
|
||
)}
|
||
{ph.kind === 'discussion' && !my.canAdjust && v.options.timeAdjust && <span className="small muted">시간 바꾸기는 하루에 한 번만 할 수 있어요.</span>}
|
||
<div className="mf-big">
|
||
<button type="button" className="btn btn-primary" disabled={pending || !my.canReady} onClick={() => act({ type: 'readyEarly' })}>
|
||
{my.ready ? `기다리는 중 (${ph.readyCount}/${ph.readyNeeded})` : `${ph.kind === 'greeting' ? '인사 끝내기' : '토론 끝내기'} (${ph.readyCount}/${ph.readyNeeded})`}
|
||
</button>
|
||
</div>
|
||
<span className="small muted">살아 있는 사람이 모두 누르면 바로 다음으로 넘어가요.</span>
|
||
</>
|
||
);
|
||
} else if (ph.kind === 'vote') {
|
||
body = (
|
||
<>
|
||
<p className="mf-prompt">마피아로 의심되는 사람을 골라 주세요</p>
|
||
<span className="small">초록 테두리 사람을 누르면 지목해요. 시간 안에는 바꿀 수 있어요.</span>
|
||
<strong>{my.vote === null ? '아직 고르지 않았어요 (안 고르면 기권)' : my.vote === 'abstain' ? '기권했어요' : `${n(my.vote)}을 지목했어요`}</strong>
|
||
<div className="mf-big">
|
||
<button type="button" className="btn btn-secondary" disabled={pending || my.vote === 'abstain'} onClick={() => act({ type: 'vote', target: 'abstain' })}>
|
||
기권
|
||
</button>
|
||
</div>
|
||
</>
|
||
);
|
||
} else if (ph.kind === 'defense') {
|
||
body = <p className="mf-prompt">{ph.accused === me ? '지목됐어요. 전체 채팅으로 최후의 반론을 해 주세요!' : `${n(ph.accused!)}의 최후의 반론을 들어 주세요.`}</p>;
|
||
} else if (ph.kind === 'confirm') {
|
||
body = my.canConfirm ? (
|
||
<>
|
||
<p className="mf-prompt">{n(ph.accused!)}을 탈락시킬까요?</p>
|
||
<div className="mf-big" role="group" aria-label="찬반 투표">
|
||
<button type="button" className={`btn ${my.ballot === 'yes' ? 'btn-danger' : 'btn-secondary'}`} aria-pressed={my.ballot === 'yes'} disabled={pending} onClick={() => act({ type: 'confirm', choice: 'yes' })}>
|
||
찬성 (탈락)
|
||
</button>
|
||
<button type="button" className={`btn ${my.ballot === 'no' ? 'btn-primary' : 'btn-secondary'}`} aria-pressed={my.ballot === 'no'} disabled={pending} onClick={() => act({ type: 'confirm', choice: 'no' })}>
|
||
반대 (살리기)
|
||
</button>
|
||
</div>
|
||
<span className="small muted">{my.ballot ? `${my.ballot === 'yes' ? '찬성' : '반대'}했어요. 시간 안에는 바꿀 수 있어요.` : '고르지 않으면 집계에서 빠져요.'}</span>
|
||
</>
|
||
) : (
|
||
<p className="mf-prompt">다른 사람들이 찬반 투표를 하고 있어요.</p>
|
||
);
|
||
} else if (ph.kind === 'night' && my.night) {
|
||
const nv = my.night;
|
||
body = (
|
||
<>
|
||
<p className="mf-prompt">{nv.prompt}</p>
|
||
{nv.action && nv.targets.length > 0 && (
|
||
<>
|
||
<span className="small">초록 테두리 사람을 누르면 골라요. 밤이 끝날 때 고른 사람으로 정해져요.</span>
|
||
<strong>{nv.choice ? `고른 사람: ${n(nv.choice)}${nv.pickedBy && nv.pickedBy !== me ? ` (${n(nv.pickedBy)}이 골랐어요)` : ''}` : '아직 고르지 않았어요'}</strong>
|
||
{nv.action === 'mafiaTarget' && nv.choice && (
|
||
<div className="mf-big">
|
||
<button type="button" className="btn btn-secondary" disabled={pending} onClick={() => act({ type: 'mafiaTarget', target: null })}>
|
||
선택 취소
|
||
</button>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
{!nv.action && <span className="small muted">밤이 지나면 아침 발표가 있어요.</span>}
|
||
</>
|
||
);
|
||
}
|
||
return (
|
||
<section className="mf-box" aria-label="할 일">
|
||
<h2>{ph.kind === 'night' ? '🌙 오늘 밤' : ph.kind === 'ended' ? '🏁 결과' : '☀️ 할 일'}</h2>
|
||
{body}
|
||
{my?.idleWarning && ph.kind !== 'ended' && <strong style={{ color: 'var(--danger)' }}>오늘도 아무것도 하지 않으면 자리 비움으로 탈락해요.</strong>}
|
||
{my && my.results.length > 0 && (
|
||
<>
|
||
<strong className="small">{my.role === 'medium' ? '영매 결과 (나만 봐요)' : '조사 결과 (나만 봐요)'}</strong>
|
||
<ul className="mf-results">
|
||
{my.results.map((r) => (
|
||
<li key={r.id}>{resultText(r, players)}</li>
|
||
))}
|
||
</ul>
|
||
</>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function FinalReveal({ v, players }: { v: MafiaView; players: Record<string, PublicUser> }) {
|
||
const f = v.final!;
|
||
return (
|
||
<section className="mf-box mf-final" aria-label="최종 공개">
|
||
<h2>
|
||
🏆 {TEAM_KO[f.winner]} 승리 · {f.days}일차
|
||
</h2>
|
||
<span>{f.summary}</span>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th scope="col">이름</th>
|
||
<th scope="col">직업</th>
|
||
<th scope="col">결과</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{f.ranking.map((id) => {
|
||
const p = f.players.find((x) => x.id === id)!;
|
||
return (
|
||
<tr key={id}>
|
||
<th scope="row">
|
||
{p.won ? '🏆 ' : ''}
|
||
{nick(players, id)}
|
||
{f.mvp === id ? ' ⭐MVP' : ''}
|
||
</th>
|
||
<td>
|
||
{ROLE_INFO[p.role].icon} {ROLE_INFO[p.role].name}
|
||
</td>
|
||
<td>{p.alive ? '생존' : `${p.diedDay}일차 ${p.diedBy === 'vote' ? '투표로' : p.diedBy === 'mafia' ? '밤에' : '자리 비움으로'} 탈락`}</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- board
|
||
|
||
function MafiaBoard({ view: v, me, players, act, pending }: BoardProps<MafiaView>) {
|
||
const ph = v.phase;
|
||
const my = v.my;
|
||
const [announce, setAnnounce] = useState('');
|
||
const phaseKey = `${v.day}-${ph.kind}`;
|
||
const first = useRef(true);
|
||
useEffect(() => {
|
||
if (first.current) {
|
||
first.current = false;
|
||
return;
|
||
}
|
||
setAnnounce(`${v.day}일차 ${PHASE_KO[ph.kind]}`);
|
||
if (my?.alive && (ph.kind === 'vote' || (ph.kind === 'confirm' && my.canConfirm) || ph.kind === 'night' || ph.kind === 'discussion')) {
|
||
haptic(40);
|
||
sounds.myTurn();
|
||
}
|
||
}, [phaseKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
let pick: Pick = null;
|
||
if (my?.alive && ph.kind === 'vote') pick = { ids: my.voteTargets, chosen: my.vote === 'abstain' ? null : my.vote, onPick: (id) => act({ type: 'vote', target: id }) };
|
||
else if (my?.alive && ph.kind === 'night' && my.night?.action && my.night.targets.length) {
|
||
const action = my.night.action;
|
||
pick = { ids: my.night.targets, chosen: my.night.choice, onPick: (id) => act({ type: action, target: id }) };
|
||
}
|
||
const time = ph.kind === 'ended' ? 'end' : isNight(v) ? 'night' : 'day';
|
||
const sub =
|
||
ph.kind === 'defense' || ph.kind === 'confirm'
|
||
? `${nick(players, ph.accused!)}님이 지목됐어요`
|
||
: ph.kind === 'discussion' && ph.adjustedBy.length
|
||
? `시간 바꾼 사람: ${ph.adjustedBy.map((id) => nick(players, id)).join(', ')}`
|
||
: ph.kind === 'ended'
|
||
? v.final?.summary
|
||
: `살아 있는 사람 ${v.aliveCount}명`;
|
||
return (
|
||
<div className="mf">
|
||
<style>{CSS}</style>
|
||
<section className="mf-banner" data-time={time} aria-label="진행 상황">
|
||
<span className="mf-ph">
|
||
<span className="d">
|
||
{time === 'night' ? '🌙' : time === 'end' ? '🏁' : '☀️'} {v.day}일차
|
||
</span>
|
||
<strong>{PHASE_KO[ph.kind]}</strong>
|
||
<span className="s">{sub}</span>
|
||
</span>
|
||
<Clock endsAt={ph.endsAt} />
|
||
{my && <RoleCard role={my.role} mates={my.mates} players={players} always={!!v.winner || !my.alive} />}
|
||
</section>
|
||
<News v={v} players={players} />
|
||
{v.final && <FinalReveal v={v} players={players} />}
|
||
<div className="mf-grid">
|
||
<div className="mf-col mf-players">
|
||
<Players v={v} players={players} me={me} pick={pick} pending={pending} />
|
||
</div>
|
||
<div className="mf-col mf-chatwrap">
|
||
<ChatBox v={v} players={players} me={me} act={act} />
|
||
</div>
|
||
<div className="mf-col mf-actions">
|
||
<Actions v={v} players={players} me={me} act={act} pending={pending} />
|
||
</div>
|
||
</div>
|
||
<p className="sr-only" aria-live="polite">
|
||
{announce}
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- side
|
||
|
||
function composition(setup: MafiaView['setup']): string {
|
||
return ROLE_ORDER.filter((r) => setup[r])
|
||
.map((r) => `${ROLE_INFO[r].name} ${setup[r]}`)
|
||
.join(' · ');
|
||
}
|
||
|
||
function MafiaSide({ view: v, players }: BoardProps<MafiaView>) {
|
||
return (
|
||
<>
|
||
<style>{CSS}</style>
|
||
<div className="card stack" style={{ gap: 6, padding: 12 }}>
|
||
<strong>
|
||
마피아 · {v.day}일차 {PHASE_KO[v.phase.kind]}
|
||
</strong>
|
||
<span className="small">이번 판 직업: {composition(v.setup)}</span>
|
||
<span className="small muted">{optionsSummary(v.options)}</span>
|
||
</div>
|
||
{v.events.length > 0 && (
|
||
<details className="card wide-open" open style={{ padding: 12 }}>
|
||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>발표 기록</summary>
|
||
<ol className="mf-log">
|
||
{[...v.events]
|
||
.reverse()
|
||
.filter((e) => e.t !== 'phase')
|
||
.map((e) => (
|
||
<li key={e.n} className="small">
|
||
{e.day}일차 · {eventText(e, players)}
|
||
</li>
|
||
))}
|
||
</ol>
|
||
</details>
|
||
)}
|
||
<p className="small muted" style={{ margin: 0 }}>
|
||
탈락한 사람은 음성 채팅에서도 말하지 않기로 해요. 게임 중에는 방 채팅 대신 게임 화면의 채팅을 써요.
|
||
</p>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- status
|
||
|
||
function isMyTurn(v: MafiaView, me: string | null): boolean {
|
||
const my = v.my;
|
||
if (!me || !my || !my.alive || v.winner) return false;
|
||
switch (v.phase.kind) {
|
||
case 'vote':
|
||
return my.vote === null;
|
||
case 'defense':
|
||
return v.phase.accused === me;
|
||
case 'confirm':
|
||
return my.canConfirm && my.ballot === null;
|
||
default:
|
||
// Night is the same for every role, so the status bar never gives a role away (docs §9).
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function statusText(v: MafiaView, me: string | null, players: Record<string, PublicUser>): string {
|
||
if (v.final) return v.final.summary;
|
||
const ph = v.phase;
|
||
const my = v.my;
|
||
const accused = ph.accused ? `${nick(players, ph.accused)}님` : '';
|
||
const prefix = my && !my.alive ? '탈락했어요 · ' : '';
|
||
switch (ph.kind) {
|
||
case 'roleReveal':
|
||
return '내 직업을 확인해 주세요';
|
||
case 'greeting':
|
||
return `${prefix}인사 시간이에요 · 오늘은 투표가 없어요`;
|
||
case 'discussion':
|
||
return `${prefix}${v.day}일차 낮 토론 시간이에요`;
|
||
case 'vote':
|
||
return isMyTurn(v, me) ? '내 차례예요 · 마피아로 의심되는 사람을 골라 주세요' : `${prefix}지목 투표 중이에요`;
|
||
case 'defense':
|
||
return ph.accused === me ? '내 차례예요 · 최후의 반론을 해 주세요' : `${prefix}${accused}의 최후의 반론이에요`;
|
||
case 'confirm':
|
||
return isMyTurn(v, me) ? `내 차례예요 · ${accused}을 탈락시킬지 골라 주세요` : `${prefix}${accused} 찬반 투표 중이에요`;
|
||
case 'night':
|
||
return my?.alive ? '밤이에요 · 오늘 밤 할 일을 확인해 주세요' : `${prefix}밤이에요`;
|
||
case 'ended':
|
||
return '게임이 끝났어요';
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 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, '끄기'],
|
||
];
|
||
const secs = <T extends number>(...v: T[]): [T, string][] => v.map((x) => [x, `${x}초`]);
|
||
const EXTRA_KO: Record<keyof MafiaOptions['extraRoles'], string> = { soldier: '군인', politician: '정치인', medium: '영매', lovers: '연인(2명)' };
|
||
|
||
function Options({ value: o, onChange }: OptionsProps<MafiaOptions>) {
|
||
const [more, setMore] = useState(false);
|
||
const set = (patch: Partial<MafiaOptions>) => onChange({ ...o, ...patch });
|
||
return (
|
||
<div className="stack" style={{ gap: 16 }}>
|
||
<Field label="마피아 수" help="자동: 4~5명 1명, 6~8명 2명, 9명 이상 3명">
|
||
<Seg
|
||
label="마피아 수"
|
||
value={o.mafiaCount}
|
||
options={[
|
||
['auto', '자동'],
|
||
[1, '1명'],
|
||
[2, '2명'],
|
||
[3, '3명'],
|
||
[4, '4명'],
|
||
]}
|
||
onChange={(mafiaCount) => set({ mafiaCount })}
|
||
/>
|
||
</Field>
|
||
<Field label="추가 직업" help="시민 자리를 대신해요. 인원이 모자라면 시작할 때 알려 줘요.">
|
||
<div className="segmented" role="group" aria-label="추가 직업">
|
||
{(Object.keys(EXTRA_KO) as (keyof MafiaOptions['extraRoles'])[]).map((k) => (
|
||
<button key={k} type="button" aria-pressed={o.extraRoles[k]} onClick={() => set({ extraRoles: { ...o.extraRoles, [k]: !o.extraRoles[k] } })}>
|
||
{EXTRA_KO[k]}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</Field>
|
||
<Field label="시작 페이즈">
|
||
<Seg
|
||
label="시작 페이즈"
|
||
value={o.firstPhase}
|
||
options={[
|
||
['day', '인사하는 낮부터'],
|
||
['night', '밤부터'],
|
||
]}
|
||
onChange={(firstPhase) => set({ firstPhase })}
|
||
/>
|
||
</Field>
|
||
<Field label="밤 시간">
|
||
<Seg label="밤 시간" value={o.nightSeconds} options={secs(15, 20, 30, 45, 60, 90)} onChange={(nightSeconds) => set({ nightSeconds })} />
|
||
</Field>
|
||
<Field label="탈락한 사람 공개">
|
||
<Seg
|
||
label="탈락한 사람 공개"
|
||
value={o.deathReveal}
|
||
options={[
|
||
['none', '공개 안 함'],
|
||
['team', '마피아인지만'],
|
||
['role', '직업까지'],
|
||
]}
|
||
onChange={(deathReveal) => set({ deathReveal })}
|
||
/>
|
||
</Field>
|
||
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
|
||
</button>
|
||
{more && (
|
||
<>
|
||
<Field label="토론 시간(살아 있는 사람 1명당)">
|
||
<Seg label="토론 시간(1명당)" value={o.discussionPerAlive} options={secs(10, 15, 20)} onChange={(discussionPerAlive) => set({ discussionPerAlive })} />
|
||
</Field>
|
||
<Field label="토론 시간 최소">
|
||
<Seg label="토론 시간 최소" value={o.discussionMin} options={secs(30, 45, 60, 90, 120)} onChange={(discussionMin) => set({ discussionMin })} />
|
||
</Field>
|
||
<Field label="토론 시간 최대">
|
||
<Seg label="토론 시간 최대" value={o.discussionMax} options={secs(60, 120, 180, 240, 300)} onChange={(discussionMax) => set({ discussionMax })} />
|
||
</Field>
|
||
<Field label="지목 투표 시간">
|
||
<Seg label="지목 투표 시간" value={o.voteSeconds} options={secs(10, 15, 20, 30, 45, 60)} onChange={(voteSeconds) => set({ voteSeconds })} />
|
||
</Field>
|
||
<Field label="최후의 반론 시간">
|
||
<Seg label="최후의 반론 시간" value={o.defenseSeconds} options={secs(10, 15, 20, 30, 45, 60)} onChange={(defenseSeconds) => set({ defenseSeconds })} />
|
||
</Field>
|
||
<Field label="찬반 투표 시간">
|
||
<Seg label="찬반 투표 시간" value={o.confirmSeconds} options={secs(5, 10, 15, 20, 30)} onChange={(confirmSeconds) => set({ confirmSeconds })} />
|
||
</Field>
|
||
<Field label="하루 한 번 토론 시간 ±15초">
|
||
<Seg label="토론 시간 바꾸기" value={o.timeAdjust} options={onOff} onChange={(timeAdjust) => set({ timeAdjust })} />
|
||
</Field>
|
||
<Field label="지목 투표에서 누가 누구를 골랐는지 공개">
|
||
<Seg label="투표 공개" value={o.openVote} options={onOff} onChange={(openVote) => set({ openVote })} />
|
||
</Field>
|
||
<Field label="의사가 자기 자신 지키기">
|
||
<Seg label="의사 자기 치료" value={o.doctorSelfHeal} options={onOff} onChange={(doctorSelfHeal) => set({ doctorSelfHeal })} />
|
||
</Field>
|
||
<Field label="의사가 같은 사람을 이틀 연속 지키기">
|
||
<Seg label="의사 연속 치료" value={o.doctorRepeat} options={onOff} onChange={(doctorRepeat) => set({ doctorRepeat })} />
|
||
</Field>
|
||
<Field label="마피아가 아무도 안 골랐을 때">
|
||
<Seg
|
||
label="마피아 미선택"
|
||
value={o.mafiaMustKill}
|
||
options={[
|
||
['noKill', '아무도 탈락 안 함'],
|
||
['random', '무작위로 1명'],
|
||
]}
|
||
onChange={(mafiaMustKill) => set({ mafiaMustKill })}
|
||
/>
|
||
</Field>
|
||
<Field label="(하우스 룰) 경찰이 마피아 여부 대신 직업을 알아요">
|
||
<Seg label="경찰 직업 확인" value={o.policeRevealRole} options={onOff} onChange={(policeRevealRole) => set({ policeRevealRole })} />
|
||
</Field>
|
||
<Field label="탈락한 사람이 모든 직업과 마피아 채팅 보기">
|
||
<Seg label="탈락자 모두 보기" value={o.deadSeeAll} options={onOff} onChange={(deadSeeAll) => set({ deadSeeAll })} />
|
||
</Field>
|
||
<Field label="고운 말 필터(욕설·링크·전화번호 막기)">
|
||
<Seg label="고운 말 필터" value={o.kidSafeChat} options={onOff} onChange={(kidSafeChat) => set({ kidSafeChat })} />
|
||
</Field>
|
||
<Field label="게임이 끝나면 모든 채팅 공개">
|
||
<Seg label="끝나면 채팅 공개" value={o.revealChatsAtEnd} options={onOff} onChange={(revealChatsAtEnd) => set({ revealChatsAtEnd })} />
|
||
</Field>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function optionsSummary(o: MafiaOptions): string {
|
||
const extras = (Object.keys(EXTRA_KO) as (keyof MafiaOptions['extraRoles'])[]).filter((k) => o.extraRoles[k]).map((k) => EXTRA_KO[k]);
|
||
const parts = [o.mafiaCount === 'auto' ? '마피아 자동' : `마피아 ${o.mafiaCount}명`];
|
||
if (extras.length) parts.push(`추가: ${extras.join('·')}`);
|
||
parts.push(o.firstPhase === 'day' ? '낮부터' : '밤부터', `밤 ${o.nightSeconds}초`, `투표 ${o.voteSeconds}초`);
|
||
parts.push(o.deathReveal === 'none' ? '탈락자 비공개' : o.deathReveal === 'team' ? '탈락 시 마피아 여부 공개' : '탈락 시 직업 공개');
|
||
return parts.join(' · ');
|
||
}
|
||
|
||
// ---------------------------------------------------------------- rules
|
||
|
||
function Rules() {
|
||
return (
|
||
<div className="stack">
|
||
<p>
|
||
4~12명이 비밀 직업을 받아 <strong>시민 팀</strong>과 <strong>마피아 팀</strong>으로 나뉘어요. 진행은 서버가 맡아요. 시민 팀은 마피아를 모두 찾으면, 마피아 팀은 살아 있는 마피아 수가 다른 사람
|
||
수와 같아지면 이겨요.
|
||
</p>
|
||
<p>
|
||
<strong>하루 흐름</strong>: ☀️ 낮 토론 → 지목 투표(가장 많이 지목된 1명) → 최후의 반론 → 찬반 투표(찬성이 많으면 탈락) → 🌙 밤(직업별 비밀 행동) → 아침 발표. 첫날은 인사만 하고 투표 없이
|
||
밤이 돼요.
|
||
</p>
|
||
<p>
|
||
밤에는 낮 채팅이 잠겨요. 마피아는 마피아 채팅으로, 연인은 연인 채팅으로 몰래 이야기해요. 탈락한 사람은 죽은 사람 채팅에서만 이야기할 수 있어요(영매는 밤에 함께해요).
|
||
</p>
|
||
<table className="mf-rules small" style={{ borderCollapse: 'collapse', width: '100%' }}>
|
||
<tbody>
|
||
{ROLE_ORDER.map((r) => (
|
||
<tr key={r}>
|
||
<td style={{ width: 28, fontSize: '1.2rem' }} aria-hidden>
|
||
{ROLE_INFO[r].icon}
|
||
</td>
|
||
<th scope="row" style={{ whiteSpace: 'nowrap' }}>
|
||
{ROLE_INFO[r].name}
|
||
</th>
|
||
<td>{ROLE_INFO[r].goal}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
<p className="small muted">
|
||
내 직업은 "내 직업" 카드를 꾹 누르고 있을 때만 보여요. 탈락한 사람은 음성 채팅에서도 말하지 않기로 해요. 게임 중에는 방 채팅 대신 게임 화면의 채팅을 써요.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const ui: GameUI<MafiaView, MafiaOptions> = {
|
||
Board: MafiaBoard,
|
||
Side: MafiaSide,
|
||
statusText,
|
||
isMyTurn,
|
||
Options,
|
||
optionsSummary,
|
||
Rules,
|
||
ownChat: true,
|
||
};
|
||
|
||
export default ui;
|