Files
joke-app/apps/web/src/pages/RoomPage.tsx

715 lines
30 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 방: 대기실 ↔ 게임 ↔ 결과를 한 주소에서 전환 (docs/06 §5–§9). */
import { useEffect, useMemo, useRef, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router';
import { botsAllowed, formatRoomCode, isBotId, type PublicUser, type RoomView } from '@bg/shared/lite';
import { Avatar, CodeLabel, ConfirmSheet, CopyButton, ShareButton, Sheet, TopBar } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm';
import { GameNotice, gameName } from '../components/GameNotice';
import { QrModal } from '../components/QrModal';
import { api, type RoomPreview } from '../net/api';
import { socket, useRoom } from '../net/socket';
import { useSession } from '../store/session';
import { loadGameUI } from '../games/registry';
import type { GameUI } from '../games/types';
import { sounds } from '../lib/feedback';
import { ChatPanel, EmoteBubbles, useChatSeen } from '../components/ChatPanel';
import { useWide } from '../lib/media';
import { GameHelp } from '../components/GameHelp';
export default function RoomPage() {
const { code = '' } = useParams();
const me = useSession((s) => s.me);
const nav = useNavigate();
const [preview, setPreview] = useState<RoomPreview | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
useEffect(() => {
if (!/^\d{6}$/.test(code)) {
setPreviewError('방 코드가 올바르지 않아요.');
return;
}
if (!me) api.room(code).then(setPreview, (e) => setPreviewError((e as Error).message));
}, [code, me]);
useEffect(() => {
if (me && /^\d{6}$/.test(code)) socket.join(code);
}, [code, me]);
if (!me) {
return (
<>
<TopBar title="방에 들어가기" onBack={() => nav('/')} />
<main className="page">
{previewError ? (
<RoomGone message={previewError} />
) : (
<section className="card stack">
{preview ? (
<>
<h1>
{gameName(preview.gameId)} 방에 초대받았어요
</h1>
<p className="muted">
방장 {preview.host} · {preview.seated}/{preview.maxPlayers}명 · 코드 {preview.codeLabel}
</p>
</>
) : (
<p className="muted">방 정보를 불러오는 중…</p>
)}
<NicknameForm next={`/r/${code}`} submitLabel="이 이름으로 입장하기" />
</section>
)}
</main>
</>
);
}
return <Room code={code} me={me} />;
}
function RoomGone({ message }: { message: string }) {
const nav = useNavigate();
return (
<section className="card stack">
<h1>{message}</h1>
<button className="btn btn-primary btn-block" onClick={() => nav('/j')}>
다른 코드 입력하기
</button>
<button className="btn btn-secondary btn-block" onClick={() => nav('/')}>
홈으로
</button>
</section>
);
}
function Room({ code, me }: { code: string; me: PublicUser }) {
const nav = useNavigate();
const { room, game, result, status, joinError, pending, slow, needsRefresh } = useRoom();
const [ui, setUi] = useState<GameUI | null>(null);
const [menu, setMenu] = useState(false);
const [leaveAsk, setLeaveAsk] = useState(false);
const [chatOpen, setChatOpen] = useState(false);
const gameId = room?.gameId;
useEffect(() => {
if (gameId) loadGameUI(gameId).then(setUi);
}, [gameId]);
const players = useMemo(() => {
const map: Record<string, PublicUser> = {};
for (const s of room?.seats ?? []) if (s) map[s.user.id] = s.user;
for (const u of room?.spectators ?? []) map[u.id] = u;
return map;
}, [room]);
useTurnTitle(ui, game?.view, me.id);
if (status === 'replaced') {
return (
<main className="page">
<section className="card stack">
<h1>다른 창에서 이 방을 열었어요</h1>
<p className="muted">한 번에 한 창에서만 할 수 있어요.</p>
<button className="btn btn-primary btn-block" onClick={() => socket.reclaim()}>
여기서 계속하기
</button>
</section>
</main>
);
}
if (joinError) {
return (
<>
<TopBar title="방" onBack={() => nav('/')} />
<main className="page">
<RoomGone message={joinError} />
</main>
</>
);
}
if (!room || room.code !== code || !ui) {
return (
<>
<TopBar title={`방 ${formatRoomCode(code)}`} onBack={() => nav('/')} />
<main className="page muted">방에 들어가는 중…</main>
</>
);
}
const g = { nameKo: gameName(room.gameId) };
const inGame = room.status === 'playing' || room.status === 'paused' || (room.status === 'finished' && !!game);
// Games with their own chat channels (마피아) close the room chat while playing (the server refuses it too).
const roomChatClosed = !!ui.ownChat && (room.status === 'playing' || room.status === 'paused');
const isHost = room.hostId === me.id;
const doLeave = () => {
socket.leave();
nav('/');
};
return (
<>
<nav className="room-bar" aria-label="방 메뉴">
<h1 className="sr-only">
{g.nameKo} 방 {formatRoomCode(code)}
</h1>
<div className="room-bar-inner">
<button className="btn btn-ghost btn-sm" onClick={() => nav('/')} aria-label="홈으로">
← 홈
</button>
<strong className="rb-game" style={{ fontSize: '1.1rem' }}>
{g.nameKo}
</strong>
<CopyButton text={`${location.origin}/r/${code}`} label={<>방 <CodeLabel code={code} /> 🔗</>} done="링크 복사됨 ✓" className="btn btn-ghost btn-sm" />
<span style={{ flex: 1 }} />
{!roomChatClosed && (
<span className="phone-only">
<ChatButton onOpen={() => setChatOpen(true)} />
</span>
)}
<button className="btn btn-ghost btn-sm wide-only" onClick={() => nav(`/rules/${room.gameId}`)}>
📖 규칙
</button>
<button className="btn btn-secondary btn-sm" onClick={() => setMenu(true)} aria-label="메뉴">
⋯ 메뉴
</button>
</div>
</nav>
{needsRefresh && (
<div className="card row" style={{ margin: 12, justifyContent: 'space-between' }}>
<span>새 버전이 있어요.</span>
<button className="btn btn-primary btn-sm" onClick={() => location.reload()}>
새로고침
</button>
</div>
)}
{inGame && game ? (
<GameView room={room} ui={ui} me={me} players={players} pending={pending !== null} slow={slow} />
) : (
<Lobby room={room} ui={ui} me={me} />
)}
{room.status === 'finished' && result && game && <ResultPanel room={room} me={me} players={players} onLeave={doLeave} />}
<EmoteBubbles room={room} />
{chatOpen && !roomChatClosed && (
<Sheet onClose={() => setChatOpen(false)} label="채팅">
<ChatPanel room={room} me={me} />
<button className="btn btn-secondary btn-block" onClick={() => setChatOpen(false)}>
닫기
</button>
</Sheet>
)}
{menu && (
<Sheet onClose={() => setMenu(false)} label="메뉴">
<h2>메뉴</h2>
<button className="btn btn-secondary btn-block" onClick={() => nav(`/rules/${room.gameId}`)}>
📖 규칙 보기
</button>
<button className="btn btn-secondary btn-block" onClick={() => nav('/me')}>
⚙️ 글자 크기 · 소리 설정
</button>
{isHost && room.status === 'paused' && (
<button className="btn btn-danger btn-block" onClick={() => (socket.send({ t: 'void' }), setMenu(false))}>
게임 끝내기(무효)
</button>
)}
<button className="btn btn-danger btn-block" onClick={() => (setMenu(false), setLeaveAsk(true))}>
🚪 방 나가기
</button>
</Sheet>
)}
{leaveAsk && (
<ConfirmSheet
title="방에서 나갈까요?"
body={room.status === 'playing' && room.seats.some((s) => s?.user.id === me.id) ? '게임 중에 나가면 기권으로 처리돼요.' : undefined}
confirm="나가기"
danger
onConfirm={doLeave}
onClose={() => setLeaveAsk(false)}
/>
)}
</>
);
}
/** Blink the tab title when it's my turn and the tab is hidden (docs/07 §6). */
function useTurnTitle(ui: GameUI | null, view: unknown, me: string) {
const myTurn = !!ui && !!view && ui.isMyTurn(view, me);
const prev = useRef(false);
useEffect(() => {
if (myTurn && !prev.current && document.visibilityState === 'hidden') sounds.myTurn();
prev.current = myTurn;
if (!myTurn) return;
const base = document.title;
let on = false;
const t = setInterval(() => {
if (document.visibilityState === 'visible') {
document.title = base;
return;
}
on = !on;
document.title = on ? '● 내 차례예요' : base;
}, 1000);
return () => {
clearInterval(t);
document.title = base;
};
}, [myTurn]);
}
// ---------------------------------------------------------------- lobby
function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser }) {
const [qr, setQr] = useState(false);
const [settings, setSettings] = useState(false);
const isHost = room.hostId === me.id;
const mySeat = room.seats.findIndex((s) => s?.user.id === me.id);
const seated = room.seats.filter(Boolean).length;
const need = Math.max(0, room.minPlayers - seated);
const url = `${location.origin}/r/${room.code}`;
const gName = gameName(room.gameId);
const host = room.seats.find((s) => s?.user.id === room.hostId)?.user.nickname ?? me.nickname;
const shareText = `${host}님이 ${gName} 방에 초대했어요! 코드 ${formatRoomCode(room.code)}`;
const notReady = room.seats.filter((s) => s && !s.ready && s.user.id !== room.hostId).length;
const [askStart, setAskStart] = useState(false);
// Computer players (docs/06 §12): the host fills empty seats; a room with them is a practice room.
const canAddBot = isHost && botsAllowed(room.gameId);
const practice = room.seats.some((s) => s && isBotId(s.user.id));
// Offered on the first empty seat only, so big rooms don't show a column of the same button.
const firstEmpty = room.seats.findIndex((s) => s === null);
const wide = useWide();
const invite = (
<section className="card stack" style={{ textAlign: 'center' }} aria-label="초대하기">
<p className="muted">친구에게 이 코드를 알려 주세요</p>
<p style={{ fontSize: '3.2rem', fontWeight: 800, lineHeight: 1.1 }}>
<CodeLabel code={room.code} />
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 8 }}>
<CopyButton text={room.code} label="🔢 코드 복사" />
<CopyButton text={url} label="🔗 링크 복사" />
<ShareButton title={`${gName} 같이 해요`} text={shareText} url={url} />
<button className="btn btn-secondary" onClick={() => setQr(true)}>
📷 QR 보기
</button>
</div>
</section>
);
const seats = (
<section className="stack" aria-label="자리">
<h2>
자리 ({seated}/{room.maxPlayers})
</h2>
<div style={{ display: 'grid', gridTemplateColumns: wide && room.seats.length > 4 ? '1fr 1fr' : '1fr', gap: 10 }}>
{room.seats.map((s, i) =>
s && isBotId(s.user.id) ? (
<div key={i} className="card row" style={{ gap: 12, flexWrap: 'nowrap' }}>
<Avatar user={s.user} size={44} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700 }}>{s.user.nickname}</div>
<div className="row small" style={{ gap: 6 }}>
<span className="badge">🤖 컴퓨터</span>
</div>
</div>
{isHost && (
<button className="btn btn-ghost" onClick={() => socket.send({ t: 'removeBot', userId: s.user.id })} aria-label={`${s.user.nickname} 빼기`}>
✕
</button>
)}
</div>
) : s ? (
<div key={i} className="card row" style={{ gap: 12, flexWrap: 'nowrap' }}>
<Avatar user={s.user} size={44} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{room.hostId === s.user.id && <span aria-label="방장">👑 </span>}
{s.user.nickname}
{s.user.id === me.id && <span className="muted small"> (나)</span>}
</div>
<div className="row small" style={{ gap: 6 }}>
<span className="dot" style={{ background: s.connected ? 'var(--success)' : s.away ? 'var(--text-sub)' : 'var(--warning)' }} />
{s.connected ? '접속 중' : s.away ? '자리 비움' : '연결 끊김'}
{room.hostId !== s.user.id && (s.ready ? <span className="badge success">준비 완료</span> : <span className="badge">준비 중</span>)}
</div>
</div>
{isHost && s.user.id !== me.id && <HostMenu room={room} target={s.user} />}
</div>
) : (
<div key={i} className="row" style={{ gap: 8, flexWrap: 'nowrap' }}>
<button className="card row" style={{ flex: 1, border: '2px dashed var(--border)', boxShadow: 'none', cursor: 'pointer', minHeight: 76, justifyContent: 'center', color: 'var(--text-sub)' }} onClick={() => (mySeat < 0 ? socket.send({ t: 'seat', seat: i }) : setQr(true))}>
{mySeat < 0 ? '+ 여기 앉기' : '+ 빈자리 · 친구 초대하기'}
</button>
{canAddBot && i === firstEmpty && (
<button className="btn btn-secondary" style={{ minHeight: 76 }} onClick={() => socket.send({ t: 'addBot', seat: i })}>
🤖 컴퓨터 넣기
</button>
)}
</div>
),
)}
</div>
{room.spectators.length > 0 && <p className="muted small">관전 {room.spectators.length}명: {room.spectators.map((u) => u.nickname).join(', ')}</p>}
{mySeat >= 0 && room.allowSpectators && (
<button className="btn btn-ghost" style={{ alignSelf: 'flex-start' }} onClick={() => socket.send({ t: 'unseat' })}>
관전으로 바꾸기
</button>
)}
</section>
);
const settingsCard = (
<section className="card stack">
<div className="row" style={{ justifyContent: 'space-between' }}>
<div>
<h2>게임 설정</h2>
<p className="muted">{ui.optionsSummary(room.options)}</p>
</div>
{isHost && !wide && (
<button className="btn btn-secondary btn-sm" onClick={() => setSettings(true)}>
설정 바꾸기
</button>
)}
</div>
<p className="muted small">{room.visibility === 'public' ? '공개 방 (누구나 들어올 수 있어요)' : '친구만 (코드·링크로만 들어와요)'}</p>
{practice && <p className="small">🤖 연습 게임이에요. 기록에는 남지 않아요.</p>}
{room.lockedOptions.length > 0 && <p className="muted small">🔒 일부 규칙은 관리자가 정해 두어서 바꿀 수 없어요.</p>}
<GameNotice gameId={room.gameId} />
{isHost && wide && <SettingsForm room={room} ui={ui} inline />}
</section>
);
const action = (
<div className="bottom-bar inline-wide">
<div className="bottom-bar-inner">
{isHost ? (
<button className="btn btn-primary" style={{ minHeight: 64, fontSize: '1.2rem' }} disabled={need > 0} onClick={() => (notReady > 0 ? setAskStart(true) : socket.send({ t: 'start' }))}>
{need > 0 ? `${need}명 더 필요해요` : '▶ 게임 시작'}
</button>
) : mySeat >= 0 ? (
room.seats[mySeat]!.ready ? (
<button className="btn btn-secondary" style={{ minHeight: 64 }} onClick={() => socket.send({ t: 'ready', ready: false })}>
준비 취소
</button>
) : (
<button className="btn btn-success" style={{ minHeight: 64, fontSize: '1.2rem' }} onClick={() => socket.send({ t: 'ready', ready: true })}>
✓ 준비하기
</button>
)
) : (
<button className="btn btn-primary" style={{ minHeight: 64 }} disabled={!room.seats.some((s) => s === null)} onClick={() => socket.send({ t: 'seat' })}>
{room.seats.some((s) => s === null) ? '자리에 앉기' : '자리가 다 찼어요 (관전 중)'}
</button>
)}
</div>
</div>
);
return (
<main className="page">
<div className="two-col">
<div className="stack" style={{ gap: 20 }}>
{invite}
{wide ? settingsCard : seats}
{wide && (
<section className="card stack">
<h2>규칙 요약</h2>
<ui.Rules options={room.options} />
</section>
)}
{!wide && settingsCard}
</div>
<div className="stack" style={{ gap: 16, position: wide ? 'sticky' : undefined, top: 140 }}>
{wide && seats}
{action}
{wide && <ChatPanel room={room} me={me} />}
</div>
</div>
{qr && <QrModal url={url} code={room.code} onClose={() => setQr(false)} />}
{settings && <SettingsSheet room={room} ui={ui} onClose={() => setSettings(false)} />}
{askStart && (
<ConfirmSheet title="준비 안 된 사람이 있어요" body="그래도 시작할까요?" confirm="시작하기" onConfirm={() => socket.send({ t: 'start' })} onClose={() => setAskStart(false)} />
)}
</main>
);
}
function HostMenu({ room, target }: { room: RoomView; target: PublicUser }) {
const [open, setOpen] = useState(false);
const [kick, setKick] = useState(false);
void room;
return (
<>
<button className="btn btn-ghost" onClick={() => setOpen(true)} aria-label={`${target.nickname} 관리`}>
⋯
</button>
{open && (
<Sheet onClose={() => setOpen(false)} label={`${target.nickname} 관리`}>
<h2>{target.nickname}</h2>
<button className="btn btn-secondary btn-block" onClick={() => (socket.send({ t: 'host', userId: target.id }), setOpen(false))}>
👑 방장 넘기기
</button>
<button className="btn btn-danger btn-block" onClick={() => (setOpen(false), setKick(true))}>
내보내기
</button>
</Sheet>
)}
{kick && (
<ConfirmSheet
title={`${target.nickname}님을 내보낼까요?`}
body="내보낸 사람은 이 방에 다시 들어올 수 없어요."
confirm="내보내기"
danger
onConfirm={() => socket.send({ t: 'kick', userId: target.id })}
onClose={() => setKick(false)}
/>
)}
</>
);
}
function SettingsSheet({ room, ui, onClose }: { room: RoomView; ui: GameUI; onClose: () => void }) {
return (
<Sheet onClose={onClose} label="게임 설정">
<h2>게임 설정</h2>
<SettingsForm room={room} ui={ui} onDone={onClose} />
</Sheet>
);
}
/** Host settings: game options + room options. Inline on PC, in a sheet on narrow screens. */
function SettingsForm({ room, ui, inline, onDone }: { room: RoomView; ui: GameUI; inline?: boolean; onDone?: () => void }) {
const [options, setOptions] = useState(room.options);
const [visibility, setVisibility] = useState(room.visibility);
const [allowSpectators, setAllowSpectators] = useState(room.allowSpectators);
const [chatEnabled, setChatEnabled] = useState(room.chatEnabled);
// Follow server-side changes (e.g. admin locks) when not editing.
useEffect(() => setOptions(room.options), [room.options]);
const dirty =
JSON.stringify(options) !== JSON.stringify(room.options) ||
visibility !== room.visibility ||
allowSpectators !== room.allowSpectators ||
chatEnabled !== room.chatEnabled;
const save = () => {
socket.send({ t: 'config', options, visibility, allowSpectators, chatEnabled });
onDone?.();
};
const seg = (value: boolean, set: (v: boolean) => void, on: string, off: string) => (
<div className="segmented">
<button type="button" aria-pressed={value} onClick={() => set(true)}>
{on}
</button>
<button type="button" aria-pressed={!value} onClick={() => set(false)}>
{off}
</button>
</div>
);
return (
<div className="stack" style={{ gap: 14 }}>
{room.lockedOptions.length > 0 && <p className="muted small">🔒 관리자가 고정한 규칙은 바꿔도 저장되지 않아요.</p>}
<ui.Options value={options} onChange={setOptions} />
<hr style={{ border: 0, borderTop: '1px solid var(--border)', width: '100%' }} />
<div style={{ display: 'grid', gridTemplateColumns: inline ? 'repeat(3, minmax(0, 1fr))' : '1fr', gap: 12 }}>
<div className="stack" style={{ gap: 6 }}>
<strong>방 공개</strong>
{room.seats.some((s) => s && isBotId(s.user.id)) ? (
<p className="muted small">컴퓨터가 있는 방은 친구만 들어와요.</p>
) : (
seg(visibility === 'public', (v) => setVisibility(v ? 'public' : 'private'), '누구나', '친구만')
)}
</div>
<div className="stack" style={{ gap: 6 }}>
<strong>관전</strong>
{seg(allowSpectators, setAllowSpectators, '허용', '안 됨')}
</div>
<div className="stack" style={{ gap: 6 }}>
<strong>채팅</strong>
{seg(chatEnabled, setChatEnabled, '켜기', '끄기')}
</div>
</div>
<div className="row">
<button className="btn btn-primary" style={{ flex: 2 }} disabled={inline && !dirty} onClick={save}>
{inline ? (dirty ? '설정 저장' : '저장됨') : '저장'}
</button>
{!inline && (
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={onDone}>
취소
</button>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------- game
function GameView({ room, ui, me, players, pending, slow }: { room: RoomView; ui: GameUI; me: PublicUser; players: Record<string, PublicUser>; pending: boolean; slow: boolean }) {
const game = useRoom((s) => s.game)!;
const tapConfirm = useSession((s) => s.settings.tapConfirm);
const wide = useWide();
const myTurn = ui.isMyTurn(game.view, me.id);
const isPlayer = room.seats.some((s) => s?.user.id === me.id) && game.active !== undefined;
const props = {
view: game.view,
me: me.id,
players,
act: (a: unknown) => socket.act(a),
pending,
deadline: game.deadline,
active: game.active,
tapConfirm,
};
const status = (
<div
className="card turn-status"
role="status"
aria-live="polite"
style={{
padding: '14px 16px',
fontWeight: 700,
fontSize: '1.15rem',
background: myTurn ? 'var(--primary)' : 'var(--surface)',
color: myTurn ? 'var(--primary-text)' : 'var(--text)',
width: '100%',
}}
>
{room.status === 'paused' ? '오류로 게임이 멈췄어요. 방장이 끝낼 수 있어요.' : ui.statusText(game.view, me.id, players)}
{slow && <span className="small"> · 연결 확인 중…</span>}
{!isPlayer && (
<span className="badge" style={{ marginLeft: 8 }}>
관전 중
</span>
)}
</div>
);
// [?] 지금 할 수 있는 것 sits right next to the turn status (docs/07 §5).
const statusRow = (
<div className="row" style={{ flexWrap: 'nowrap', gap: 8, width: '100%', alignItems: 'stretch' }}>
{status}
<GameHelp room={room} ui={ui} title={gameName(room.gameId)} me={me.id} players={players} />
</div>
);
return (
<div className="game-layout">
<div className="game-main">
{!wide && statusRow}
<ui.Board {...props} />
</div>
<aside className="game-side" aria-label="게임 정보">
{wide && statusRow}
<ui.Side {...props} />
{wide && !(ui.ownChat && (room.status === 'playing' || room.status === 'paused')) && <ChatPanel room={room} me={me} title={false} />}
</aside>
</div>
);
}
function ResultPanel({ room, me, players, onLeave }: { room: RoomView; me: PublicUser; players: Record<string, PublicUser>; onLeave: () => void }) {
const result = useRoom((s) => s.result)!;
const [hidden, setHidden] = useState(false);
const played = useRef(false);
const seatedAll = room.seats.filter(Boolean).map((s) => s!.user.id);
const amSeated = seatedAll.includes(me.id);
// Computer players always agree to a rematch.
const seated = seatedAll.filter((u) => !isBotId(u));
const voted = room.rematchVotes.includes(me.id);
// Solo games (e.g. 요트 다이스) have one ranked player: neither a draw nor a win.
const solo = result.ranking.flat().length === 1;
const allFirst = !solo && result.ranking.length === 1;
const won = !solo && !allFirst && result.ranking[0]?.includes(me.id);
const lost = !solo && !allFirst && !won && result.ranking.some((g) => g.includes(me.id));
useEffect(() => {
if (played.current) return;
played.current = true;
if (won) sounds.win();
else if (lost) sounds.lose();
}, [won, lost]);
if (hidden) {
return (
<div className="bottom-bar">
<div className="bottom-bar-inner">
<button className="btn btn-primary" onClick={() => setHidden(false)}>
결과 다시 보기
</button>
</div>
</div>
);
}
return (
<Sheet onClose={() => setHidden(true)} label="게임 결과">
<h1 style={{ textAlign: 'center' }}>{allFirst ? '무승부예요' : won ? '🎉 이겼어요!' : lost ? '아쉽게 졌어요' : '게임이 끝났어요'}</h1>
<p style={{ textAlign: 'center' }}>{result.summary}</p>
{seatedAll.length > seated.length && <p className="muted small" style={{ textAlign: 'center' }}>🤖 연습 게임이라 기록에는 남지 않아요.</p>}
<div className="stack">
{result.ranking.map((group, i) =>
group.map((u) => (
<div key={u} className="row">
<strong style={{ width: 40 }}>{i + 1}등</strong>
{players[u] && <Avatar user={players[u]!} size={28} />}
<span>{players[u]?.nickname ?? '나간 사람'}</span>
<span className="muted small" style={{ marginLeft: 'auto' }}>
이 방에서 {room.sessionStats[u]?.wins ?? 0}승 / {room.sessionStats[u]?.played ?? 0}판
</span>
</div>
)),
)}
</div>
{amSeated && (
<button className="btn btn-primary btn-block" disabled={voted} onClick={() => socket.send({ t: 'rematch' })}>
{voted ? `다른 사람을 기다리는 중… (${room.rematchVotes.length}/${seated.length})` : `🔁 한 판 더 (${room.rematchVotes.length}/${seated.length})`}
</button>
)}
<button className="btn btn-secondary btn-block" onClick={() => setHidden(true)}>
판 다시 보기
</button>
{room.lastGamePk && result.ranking.some((g) => g.includes(me.id)) && (
<Link className="btn btn-secondary btn-block" to={`/replay/${room.lastGamePk}`}>
▶ 처음부터 다시 보기
</Link>
)}
<button className="btn btn-ghost btn-block" onClick={onLeave}>
방 나가기
</button>
{room.lastSeed && (
<FairnessCheck seedHash={room.seedHash} seed={room.lastSeed} />
)}
</Sheet>
);
}
/**
* docs/04 §5: the seed's SHA-256 was published when the game started; the seed is revealed at the end.
* The browser recomputes the hash itself so players don't have to trust the server's word.
*/
function FairnessCheck({ seedHash, seed }: { seedHash: string | null; seed: string }) {
const [verdict, setVerdict] = useState<null | boolean>(null);
const check = async () => {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(seed));
const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
setVerdict(hex === seedHash);
};
return (
<details className="small muted">
<summary>공정성 확인</summary>
<p>시작할 때 공개한 값(SHA-256): {seedHash ?? '-'}</p>
<p>끝나고 공개한 난수 씨앗: {seed}</p>
<button type="button" className="btn btn-secondary btn-sm" onClick={check}>
내 브라우저에서 직접 확인하기
</button>
{verdict !== null && (
<p style={{ fontWeight: 700, color: verdict ? 'var(--success)' : 'var(--danger)' }}>
{verdict ? '✓ 일치해요. 게임 도중 카드 순서나 난수가 바뀌지 않았어요.' : '✗ 일치하지 않아요. 관리자에게 알려 주세요.'}
</p>
)}
</details>
);
}
// ---------------------------------------------------------------- chat
function ChatButton({ onOpen }: { onOpen: () => void }) {
const total = useRoom((s) => s.chat.length);
const seen = useChatSeen((s) => s.seen);
const unread = Math.max(0, total - seen);
return (
<button className="btn btn-ghost btn-sm" onClick={onOpen} aria-label={`채팅${unread ? `, 새 메시지 ${unread}개` : ''}`}>
💬 채팅{unread > 0 && <span className="badge warning">{unread}</span>}
</button>
);
}