- 서버: 끝난 게임을 공개된 시드 + room_log 행동으로 다시 돌려 플레이어별 view 장면을 만든다 (rooms/replay.ts, 장면은 view 차이만 전송: shared/view-patch.ts). 행동 로그의 at = 적용한 정확한 시각, start 로그에 stateVersion. GET /api/me/games, GET /api/replays/:gamePk(그 판 플레이어만, no-store), RoomView.lastGamePk. 로그가 지워졌거나 규칙 버전이 바뀌었거나 결과가 다르면 한국어 안내(410). - 웹: 다시 보기 화면(/replay/:gamePk, 게임 Board/Side 읽기 전용, 처음/이전/재생·멈춤/다음/끝, 슬라이더), 내 정보의 내 게임 기록 목록, 결과 화면의 [처음부터 다시 보기]. - 테스트: 모든 게임을 Room으로 끝까지 둔 뒤 다시 만든 상태·결과·장면이 같은지, 권한, view-patch, e2e/replay.e2e.ts. - 문서: docs/09 §8, docs/07 §10. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
672 lines
27 KiB
TypeScript
672 lines
27 KiB
TypeScript
/** 방: 대기실 ↔ 게임 ↔ 결과를 한 주소에서 전환 (docs/06 §5–§9). */
|
||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
import { Link, useNavigate, useParams } from 'react-router';
|
||
import { formatRoomCode, 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';
|
||
|
||
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);
|
||
|
||
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 ? (
|
||
<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>
|
||
) : (
|
||
<button key={i} className="card row" style={{ 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>
|
||
),
|
||
)}
|
||
</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>
|
||
{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>
|
||
{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>
|
||
);
|
||
return (
|
||
<div className="game-layout">
|
||
<div className="game-main">
|
||
{!wide && status}
|
||
<ui.Board {...props} />
|
||
</div>
|
||
<aside className="game-side" aria-label="게임 정보">
|
||
{wide && status}
|
||
<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 seated = room.seats.filter(Boolean).map((s) => s!.user.id);
|
||
const amSeated = seated.includes(me.id);
|
||
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>
|
||
<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>
|
||
);
|
||
}
|