M9: 컴퓨터와 연습하기 화면과 E2E
- 게임 상세·홈 [🤖 컴퓨터와 연습하기], 대기실 [🤖 컴퓨터 넣기]·[✕], 🤖 아바타·배지, 연습 게임 안내, 한 판 더는 사람만 세기 - e2e/bots.e2e.ts: 오목 연습(컴퓨터가 여러 번 받음), 원카드 컴퓨터 2명 여러 턴 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
/** Shared UI primitives (docs/07 §4). */
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { formatRoomCode } from '@bg/shared/lite';
|
||||
import { formatRoomCode, isBotId } from '@bg/shared/lite';
|
||||
import { useToasts, toast } from '../store/toast';
|
||||
import { useRoom } from '../net/socket';
|
||||
import { haptic } from '../lib/feedback';
|
||||
@@ -14,7 +14,7 @@ export function Avatar({ user, size = 40 }: { user: Pick<PublicUser, 'id' | 'nic
|
||||
const bg = AVATAR_COLORS[h % AVATAR_COLORS.length];
|
||||
return (
|
||||
<span className="avatar" style={{ width: size, height: size, background: bg, fontSize: size * 0.45 }} aria-hidden>
|
||||
{user.avatar ? <img src={user.avatar} alt="" loading="lazy" referrerPolicy="no-referrer" /> : [...user.nickname][0]}
|
||||
{user.avatar ? <img src={user.avatar} alt="" loading="lazy" referrerPolicy="no-referrer" /> : isBotId(user.id) ? '🤖' : [...user.nickname][0]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,8 @@ export const api = {
|
||||
deleteMe: () => call<{ ok: true }>('DELETE', '/api/me', { confirm: '탈퇴' }),
|
||||
stats: () => call<{ stats: { gameId: string; played: number; wins: number; draws: number }[] }>('GET', '/api/me/stats'),
|
||||
createRoom: (gameId: string, visibility?: 'private' | 'public') => call<{ code: string }>('POST', '/api/rooms', { gameId, visibility }),
|
||||
/** 컴퓨터와 연습: a private room with enough computer players to start (docs/06 §12). */
|
||||
practice: (gameId: string) => call<{ code: string }>('POST', '/api/rooms', { gameId, practice: true }),
|
||||
quickPlay: (gameId: string) => call<{ code: string }>('POST', `/api/quick/${gameId}`, {}),
|
||||
room: (code: string) => call<RoomPreview>('GET', `/api/rooms/${code}`),
|
||||
allPublicRooms: () => call<{ rooms: { code: string; gameId: string; host: string; seated: number; max: number }[] }>('GET', '/api/public-rooms'),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** 게임 소개 + 방 만들기 / 빠른 시작 / 규칙 / 공개 방 목록 (docs/06 §2, §7, §10). */
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router';
|
||||
import { catalogById, formatRoomCode } from '@bg/shared/lite';
|
||||
import { botsAllowed, catalogById, formatRoomCode } from '@bg/shared/lite';
|
||||
import { Sheet, TopBar } from '../components/ui';
|
||||
import { NicknameForm } from '../components/NicknameForm';
|
||||
import { api } from '../net/api';
|
||||
@@ -64,6 +64,11 @@ export default function GamePage() {
|
||||
<button className="btn btn-secondary btn-block" disabled={busy} onClick={() => run(() => api.quickPlay(g.id))}>
|
||||
⚡ 아무나와 빠른 시작
|
||||
</button>
|
||||
{botsAllowed(g.id) && (
|
||||
<button className="btn btn-secondary btn-block" disabled={busy} onClick={() => run(() => api.practice(g.id))}>
|
||||
🤖 컴퓨터와 연습하기
|
||||
</button>
|
||||
)}
|
||||
<Link className="btn btn-ghost btn-block" to={`/rules/${g.id}`}>
|
||||
📖 규칙 보기
|
||||
</Link>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** 홈 (PC 기준, docs/07-ui-ux.md §3.3): 큰 행동 줄 + 게임 격자 + 열린 공개 방. */
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { CATALOG, formatRoomCode } from '@bg/shared/lite';
|
||||
import { CATALOG, botsAllowed, formatRoomCode } from '@bg/shared/lite';
|
||||
import { Sheet } from '../components/ui';
|
||||
import { NicknameForm } from '../components/NicknameForm';
|
||||
import { CodeInput } from '../components/CodeInput';
|
||||
@@ -16,12 +16,13 @@ export function Home() {
|
||||
const { me, activeRoom } = useSession();
|
||||
const catalog = useSession((s) => (s.catalog.length ? s.catalog : CATALOG));
|
||||
const nav = useNavigate();
|
||||
const [picker, setPicker] = useState(false);
|
||||
const [picker, setPicker] = useState<null | 'room' | 'practice'>(null);
|
||||
const [needName, setNeedName] = useState<null | (() => void)>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [rooms, setRooms] = useState<{ code: string; gameId: string; host: string; seated: number; max: number }[]>([]);
|
||||
const [codeKey, setCodeKey] = useState(0);
|
||||
const available = catalog.filter((g) => g.available);
|
||||
const practiceGames = available.filter((g) => botsAllowed(g.id));
|
||||
|
||||
useEffect(() => {
|
||||
const load = () => api.allPublicRooms().then((r) => setRooms(r.rooms), () => {});
|
||||
@@ -31,11 +32,11 @@ export function Home() {
|
||||
}, []);
|
||||
|
||||
const withName = (fn: () => void) => (me ? fn() : setNeedName(() => fn));
|
||||
const create = (gameId: string) =>
|
||||
const create = (gameId: string, practice = false) =>
|
||||
withName(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const { code } = await api.createRoom(gameId);
|
||||
const { code } = await (practice ? api.practice(gameId) : api.createRoom(gameId));
|
||||
nav(`/r/${code}`);
|
||||
} catch (e) {
|
||||
toast((e as Error).message, 'error');
|
||||
@@ -70,7 +71,7 @@ export function Home() {
|
||||
className="btn btn-primary btn-block"
|
||||
style={{ minHeight: 72, fontSize: '1.3rem' }}
|
||||
disabled={busy}
|
||||
onClick={() => (available.length === 1 ? create(available[0]!.id) : setPicker(true))}
|
||||
onClick={() => (available.length === 1 ? create(available[0]!.id) : setPicker('room'))}
|
||||
>
|
||||
+ 방 만들기
|
||||
</button>
|
||||
@@ -80,6 +81,15 @@ export function Home() {
|
||||
<p className="muted">받은 숫자 6자리를 넣으면 바로 들어가요.</p>
|
||||
<CodeInput key={codeKey} onComplete={joinCode} autoFocus={false} />
|
||||
</div>
|
||||
{practiceGames.length > 0 && (
|
||||
<div className="card stack" style={{ padding: 24, justifyContent: 'center' }}>
|
||||
<h2>혼자 연습하기</h2>
|
||||
<p className="muted">컴퓨터와 바로 한 판 해 봐요. 기록에는 남지 않아요.</p>
|
||||
<button className="btn btn-secondary btn-block" style={{ minHeight: 60 }} disabled={busy} onClick={() => setPicker('practice')}>
|
||||
🤖 컴퓨터와 연습하기
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="two-col" style={{ gridTemplateColumns: undefined }}>
|
||||
@@ -111,10 +121,10 @@ export function Home() {
|
||||
</div>
|
||||
|
||||
{picker && (
|
||||
<Sheet onClose={() => setPicker(false)} label="게임 고르기">
|
||||
<h2>어떤 게임을 할까요?</h2>
|
||||
{available.map((g) => (
|
||||
<button key={g.id} className="btn btn-secondary btn-block" onClick={() => (setPicker(false), create(g.id))}>
|
||||
<Sheet onClose={() => setPicker(null)} label="게임 고르기">
|
||||
<h2>{picker === 'practice' ? '컴퓨터와 어떤 게임을 할까요?' : '어떤 게임을 할까요?'}</h2>
|
||||
{(picker === 'practice' ? practiceGames : available).map((g) => (
|
||||
<button key={g.id} className="btn btn-secondary btn-block" onClick={() => (setPicker(null), create(g.id, picker === 'practice'))}>
|
||||
{GAME_ICON[g.id]} {g.nameKo}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** 방: 대기실 ↔ 게임 ↔ 결과를 한 주소에서 전환 (docs/06 §5–§9). */
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { formatRoomCode, type PublicUser, type RoomView } from '@bg/shared/lite';
|
||||
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';
|
||||
@@ -268,6 +268,11 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
|
||||
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 = (
|
||||
@@ -293,7 +298,22 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
|
||||
</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: wide && room.seats.length > 4 ? '1fr 1fr' : '1fr', gap: 10 }}>
|
||||
{room.seats.map((s, i) =>
|
||||
s ? (
|
||||
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 }}>
|
||||
@@ -311,9 +331,16 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
|
||||
{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 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>
|
||||
@@ -339,6 +366,7 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
|
||||
)}
|
||||
</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 />}
|
||||
@@ -478,7 +506,11 @@ function SettingsForm({ room, ui, inline, onDone }: { room: RoomView; ui: GameUI
|
||||
<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'), '누구나', '친구만')}
|
||||
{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>
|
||||
@@ -563,8 +595,10 @@ function ResultPanel({ room, me, players, onLeave }: { room: RoomView; me: Publi
|
||||
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 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;
|
||||
@@ -592,6 +626,7 @@ function ResultPanel({ room, me, players, onLeave }: { room: RoomView; me: Publi
|
||||
<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) => (
|
||||
|
||||
Reference in New Issue
Block a user