웹: PC 중심 화면으로 재구성
- 공통 상단 메뉴(사이트 이름·홈·게임 목록·관리자·어디서나 코드 입력·내 정보) - 홈: 방 만들기 + 코드 입력 카드, 게임 격자, 지금 열린 공개 방 목록(/api/public-rooms) - 대기실: 두 칸(초대·설정 바로 수정·규칙 요약 / 자리·시작 버튼·채팅 항상 보임) - 게임 화면: 판(화면 높이에 맞춤, 좌표 표시) + 오른쪽 패널(상태·선수·시계·버튼·기보·채팅) - 오목: 마우스 올리면 미리 보기 돌, PC는 한 번 클릭으로 착수(두 번 눌러 확정은 터치 기기 기본값, 기기별 설정), GameUI에 Side 패널 추가 - 채팅: PC는 패널로 항상 표시(Enter로 바로 입력), 휴대폰은 시트 - 관리자: 왼쪽 사이드바, 사용자 표, 규칙 입력 두 칸 격자, 저장 바 고정 - 좁은 화면은 같은 화면을 한 칸으로 접어서 지원(방 상단 줄 줄바꿈 수정) - E2E: PC 1440×900 기준(방장 PC, 친구 휴대폰), 관리자 E2E도 PC Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -31,12 +31,12 @@ function Stone({ color, cx, cy, r, ghost }: { color: Color; cx: number; cy: numb
|
||||
);
|
||||
}
|
||||
|
||||
function OmokBoard({ view: v, me, players, act, pending, deadline, tapConfirm }: BoardProps<OmokView>) {
|
||||
function OmokBoard({ view: v, act, pending, tapConfirm }: BoardProps<OmokView>) {
|
||||
const n = v.size;
|
||||
const myTurn = v.phase === 'playing' && v.myColor === v.turn;
|
||||
const [preview, setPreview] = useState<Pt | null>(null);
|
||||
const [hover, setHover] = useState<Pt | null>(null);
|
||||
const [cursor, setCursor] = useState<Pt>({ x: (n - 1) / 2, y: (n - 1) / 2 });
|
||||
const [confirm, setConfirm] = useState<null | 'resign' | 'draw'>(null);
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const [kbd, setKbd] = useState(false);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
@@ -67,18 +67,20 @@ function OmokBoard({ view: v, me, players, act, pending, deadline, tapConfirm }:
|
||||
const place = (p: Pt) => {
|
||||
act({ type: 'place', x: p.x, y: p.y });
|
||||
setPreview(null);
|
||||
setHover(null);
|
||||
};
|
||||
|
||||
const tap = (p: Pt) => {
|
||||
const tap = (p: Pt, mouse: boolean) => {
|
||||
setCursor(p);
|
||||
if (!myTurn) return;
|
||||
if (v.board[p.y * n + p.x] !== 0) return;
|
||||
if (!myTurn || v.board[p.y * n + p.x] !== 0) return;
|
||||
if (forbidden.has(p.y * n + p.x)) {
|
||||
haptic([30, 30, 30]);
|
||||
setAnnounce('여기는 금수 자리라 둘 수 없어요.');
|
||||
return;
|
||||
}
|
||||
if (!tapConfirm) {
|
||||
place(p);
|
||||
// Mouse: one click places (hover already shows the preview). Touch: optional two-step confirm.
|
||||
if (mouse || !tapConfirm) {
|
||||
if (canPlace(p)) place(p);
|
||||
return;
|
||||
}
|
||||
if (preview && preview.x === p.x && preview.y === p.y) place(p);
|
||||
@@ -110,6 +112,85 @@ function OmokBoard({ view: v, me, players, act, pending, deadline, tapConfirm }:
|
||||
};
|
||||
|
||||
const winSet = new Set((v.winLine ?? []).map((p) => p.y * n + p.x));
|
||||
const ghost = preview ?? (hover && canPlace(hover) ? hover : null);
|
||||
const pad = 0.9;
|
||||
return (
|
||||
<div className="board-wrap stack" style={{ gap: 10 }}>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`${-pad} ${-pad} ${n - 1 + 2 * pad} ${n - 1 + 2 * pad}`}
|
||||
style={{ width: '100%', display: 'block', background: 'var(--wood)', borderRadius: 12, touchAction: 'manipulation', cursor: myTurn ? 'pointer' : 'default', boxShadow: 'var(--shadow)' }}
|
||||
onPointerMove={(e) => {
|
||||
if (e.pointerType !== 'mouse' || !myTurn) return;
|
||||
const p = pointToCell(e);
|
||||
if (p?.x !== hover?.x || p?.y !== hover?.y) setHover(p);
|
||||
}}
|
||||
onPointerLeave={() => setHover(null)}
|
||||
onPointerUp={(e) => {
|
||||
setKbd(false);
|
||||
const p = pointToCell(e);
|
||||
if (p) tap(p, e.pointerType === 'mouse');
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="grid"
|
||||
aria-label={`오목판 ${n}줄. 방향키로 움직이고 Enter로 둡니다. 현재 위치 ${label(n, cursor)}`}
|
||||
onKeyDown={onKey}
|
||||
>
|
||||
{Array.from({ length: n }, (_, i) => (
|
||||
<g key={i} stroke="var(--wood-line)" strokeWidth={i === 0 || i === n - 1 ? 0.06 : 0.035}>
|
||||
<line x1={0} y1={i} x2={n - 1} y2={i} />
|
||||
<line x1={i} y1={0} x2={i} y2={n - 1} />
|
||||
</g>
|
||||
))}
|
||||
{Array.from({ length: n }, (_, i) => (
|
||||
<g key={`c${i}`} fill="var(--wood-line)" fontSize={0.38} fontWeight={700} textAnchor="middle" dominantBaseline="central" opacity={0.75}>
|
||||
<text x={i} y={-0.6}>
|
||||
{COLS[i]}
|
||||
</text>
|
||||
<text x={-0.62} y={i}>
|
||||
{n - i}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{stars(n).map((p) => (
|
||||
<circle key={`s${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.11} fill="var(--wood-line)" />
|
||||
))}
|
||||
{myTurn && v.hints?.urgent.map((p) => <circle key={`u${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.42} fill="none" stroke="#dc2626" strokeWidth={0.09} />)}
|
||||
{myTurn && v.hints?.warn.map((p) => <circle key={`w${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.38} fill="none" stroke="#f59e0b" strokeWidth={0.07} strokeDasharray="0.15 0.1" />)}
|
||||
{v.forbidden.map((p) => (
|
||||
<g key={`f${p.x}-${p.y}`} stroke="#b91c1c" strokeWidth={0.08} opacity={0.8}>
|
||||
<line x1={p.x - 0.2} y1={p.y - 0.2} x2={p.x + 0.2} y2={p.y + 0.2} />
|
||||
<line x1={p.x + 0.2} y1={p.y - 0.2} x2={p.x - 0.2} y2={p.y + 0.2} />
|
||||
</g>
|
||||
))}
|
||||
{v.board.map((c, i) => (c === 0 ? null : <Stone key={i} color={c === 1 ? 'black' : 'white'} cx={i % n} cy={Math.floor(i / n)} r={0.46} />))}
|
||||
{v.lastMove && <circle cx={v.lastMove.x} cy={v.lastMove.y} r={0.13} fill="#ef4444" />}
|
||||
{[...winSet].map((i) => (
|
||||
<circle key={`win${i}`} cx={i % n} cy={Math.floor(i / n)} r={0.5} fill="none" stroke="#22c55e" strokeWidth={0.1} />
|
||||
))}
|
||||
{ghost && v.myColor && <Stone color={v.myColor} cx={ghost.x} cy={ghost.y} r={0.46} ghost />}
|
||||
{myTurn && kbd && <rect x={cursor.x - 0.5} y={cursor.y - 0.5} width={1} height={1} fill="none" stroke="var(--focus)" strokeWidth={0.06} />}
|
||||
</svg>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
{preview && myTurn && (
|
||||
<div className="row" style={{ justifyContent: 'center' }}>
|
||||
<button className="btn btn-primary" style={{ flex: 2 }} disabled={!canPlace(preview)} onClick={() => place(preview)}>
|
||||
{`${label(n, preview)}에 두기`}
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => setPreview(null)}>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Right-hand panel on PC (below the board on narrow screens). */
|
||||
function OmokSide({ view: v, me, players, act, pending, deadline }: BoardProps<OmokView>) {
|
||||
const [confirm, setConfirm] = useState<null | 'resign' | 'draw'>(null);
|
||||
const incoming =
|
||||
v.phase === 'playing' && v.myColor
|
||||
? v.undo.pending && v.undo.pending.by !== v.myColor
|
||||
@@ -120,86 +201,27 @@ function OmokBoard({ view: v, me, players, act, pending, deadline, tapConfirm }:
|
||||
: null;
|
||||
const outgoing = v.myColor && (v.undo.pending?.by === v.myColor || v.draw.pendingBy === v.myColor);
|
||||
const undoLeft = v.myColor ? v.undo.remaining[v.myColor] : null;
|
||||
const opp: Color | null = v.myColor ? (v.myColor === 'black' ? 'white' : 'black') : null;
|
||||
|
||||
const pad = 0.6;
|
||||
const opp: Color = v.myColor === 'white' ? 'black' : 'white';
|
||||
const mine: Color = v.myColor ?? 'black';
|
||||
return (
|
||||
<div className="stack" style={{ gap: 10 }}>
|
||||
<PlayerBar view={v} color={opp ?? 'white'} players={players} deadline={deadline} me={me} />
|
||||
<div className="board-wrap">
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`${-pad} ${-pad} ${n - 1 + 2 * pad} ${n - 1 + 2 * pad}`}
|
||||
style={{ width: '100%', display: 'block', background: 'var(--wood)', borderRadius: 12, touchAction: 'manipulation', cursor: myTurn ? 'pointer' : 'default' }}
|
||||
onPointerUp={(e) => {
|
||||
setKbd(false);
|
||||
const p = pointToCell(e);
|
||||
if (p) tap(p);
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="grid"
|
||||
aria-label={`오목판 ${n}줄. 방향키로 움직이고 Enter로 둡니다. 현재 위치 ${label(n, cursor)}`}
|
||||
onKeyDown={onKey}
|
||||
>
|
||||
{Array.from({ length: n }, (_, i) => (
|
||||
<g key={i} stroke="var(--wood-line)" strokeWidth={i === 0 || i === n - 1 ? 0.06 : 0.035}>
|
||||
<line x1={0} y1={i} x2={n - 1} y2={i} />
|
||||
<line x1={i} y1={0} x2={i} y2={n - 1} />
|
||||
</g>
|
||||
))}
|
||||
{stars(n).map((p) => (
|
||||
<circle key={`s${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.11} fill="var(--wood-line)" />
|
||||
))}
|
||||
{myTurn && v.hints?.urgent.map((p) => <circle key={`u${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.42} fill="none" stroke="#dc2626" strokeWidth={0.09} />)}
|
||||
{myTurn && v.hints?.warn.map((p) => <circle key={`w${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.38} fill="none" stroke="#f59e0b" strokeWidth={0.07} strokeDasharray="0.15 0.1" />)}
|
||||
{v.forbidden.map((p) => (
|
||||
<g key={`f${p.x}-${p.y}`} stroke="#b91c1c" strokeWidth={0.08} opacity={0.8}>
|
||||
<line x1={p.x - 0.2} y1={p.y - 0.2} x2={p.x + 0.2} y2={p.y + 0.2} />
|
||||
<line x1={p.x + 0.2} y1={p.y - 0.2} x2={p.x - 0.2} y2={p.y + 0.2} />
|
||||
</g>
|
||||
))}
|
||||
{v.board.map((c, i) =>
|
||||
c === 0 ? null : <Stone key={i} color={c === 1 ? 'black' : 'white'} cx={i % n} cy={Math.floor(i / n)} r={0.46} />,
|
||||
)}
|
||||
{v.lastMove && <circle cx={v.lastMove.x} cy={v.lastMove.y} r={0.13} fill="#ef4444" />}
|
||||
{[...winSet].map((i) => (
|
||||
<circle key={`win${i}`} cx={i % n} cy={Math.floor(i / n)} r={0.5} fill="none" stroke="#22c55e" strokeWidth={0.1} />
|
||||
))}
|
||||
{preview && v.myColor && <Stone color={v.myColor} cx={preview.x} cy={preview.y} r={0.46} ghost />}
|
||||
{myTurn && kbd && <rect x={cursor.x - 0.5} y={cursor.y - 0.5} width={1} height={1} fill="none" stroke="var(--focus)" strokeWidth={0.06} className="kbd-cursor" />}
|
||||
</svg>
|
||||
</div>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
|
||||
{preview && myTurn && (
|
||||
<div className="row" style={{ justifyContent: 'center' }}>
|
||||
<button className="btn btn-primary" style={{ flex: 2 }} disabled={!canPlace(preview)} onClick={() => place(preview)}>
|
||||
{forbidden.has(preview.y * n + preview.x) ? '여기는 둘 수 없어요' : `${label(n, preview)}에 두기`}
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => setPreview(null)}>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PlayerBar view={v} color={v.myColor ?? 'black'} players={players} deadline={deadline} me={me} />
|
||||
|
||||
<>
|
||||
<PlayerBar view={v} color={v.myColor ? opp : 'black'} players={players} deadline={deadline} me={me} />
|
||||
<PlayerBar view={v} color={v.myColor ? mine : 'white'} players={players} deadline={deadline} me={me} />
|
||||
{v.phase === 'playing' && v.myColor && (
|
||||
<div className="row" style={{ justifyContent: 'center' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
|
||||
{v.undo.allowed && (
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={!!outgoing || undoLeft === 0 || !v.moves.some((m) => m.color === v.myColor)}
|
||||
onClick={() => act({ type: 'requestUndo' })}
|
||||
title={undoLeft !== null ? `${undoLeft}번 남음` : '무제한'}
|
||||
>
|
||||
↩ 무르기{undoLeft !== null ? ` (${undoLeft}번 남음)` : ''}
|
||||
↩ 무르기{undoLeft !== null ? ` ${undoLeft}` : ''}
|
||||
</button>
|
||||
)}
|
||||
{v.draw.allowed && (
|
||||
<button className="btn btn-secondary btn-sm" disabled={!!outgoing} onClick={() => setConfirm('draw')}>
|
||||
🤝 무승부 제안
|
||||
🤝 무승부
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-danger btn-sm" onClick={() => setConfirm('resign')}>
|
||||
@@ -208,13 +230,14 @@ function OmokBoard({ view: v, me, players, act, pending, deadline, tapConfirm }:
|
||||
</div>
|
||||
)}
|
||||
{outgoing && (
|
||||
<div className="card row" style={{ justifyContent: 'space-between' }}>
|
||||
<span>상대의 답을 기다리는 중이에요…</span>
|
||||
<button className="btn btn-ghost" onClick={() => act({ type: 'cancelOffer' })}>
|
||||
<div className="card row" style={{ justifyContent: 'space-between', padding: 10 }}>
|
||||
<span className="small">상대의 답을 기다리는 중…</span>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => act({ type: 'cancelOffer' })}>
|
||||
요청 취소
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<MoveList view={v} />
|
||||
{incoming && (
|
||||
<Sheet onClose={() => {}} label="상대의 요청">
|
||||
<h2>{incoming === 'undo' ? `상대가 ${v.undo.pending!.plies}수 무르기를 요청했어요` : '상대가 무승부를 제안했어요'}</h2>
|
||||
@@ -232,10 +255,29 @@ function OmokBoard({ view: v, me, players, act, pending, deadline, tapConfirm }:
|
||||
{confirm === 'resign' && (
|
||||
<ConfirmSheet title="기권할까요?" body="기권하면 이 판은 상대가 이겨요." confirm="기권하기" danger onConfirm={() => act({ type: 'resign' })} onClose={() => setConfirm(null)} />
|
||||
)}
|
||||
{confirm === 'draw' && (
|
||||
<ConfirmSheet title="무승부를 제안할까요?" confirm="제안하기" onConfirm={() => act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />
|
||||
)}
|
||||
</div>
|
||||
{confirm === 'draw' && <ConfirmSheet title="무승부를 제안할까요?" confirm="제안하기" onConfirm={() => act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MoveList({ view: v }: { view: OmokView }) {
|
||||
const ref = useRef<HTMLOListElement>(null);
|
||||
useEffect(() => {
|
||||
ref.current?.scrollTo({ top: ref.current.scrollHeight });
|
||||
}, [v.moveCount]);
|
||||
if (v.moveCount === 0) return null;
|
||||
return (
|
||||
<details className="card wide-open" open style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기보 ({v.moveCount}수)</summary>
|
||||
<ol ref={ref} style={{ margin: '8px 0 0', paddingLeft: 0, listStyle: 'none', maxHeight: 132, overflow: 'auto', display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '2px 8px', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{v.moves.map((m, i) => (
|
||||
<li key={i} className="small">
|
||||
<span className="muted">{i + 1}.</span> {m.color === 'black' ? '●' : '○'} {label(v.size, m.pt)}
|
||||
{m.auto ? '*' : ''}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -435,6 +477,7 @@ function Rules({ options }: { options?: OmokOptions }) {
|
||||
|
||||
const ui: GameUI<OmokView, OmokOptions> = {
|
||||
Board: OmokBoard,
|
||||
Side: OmokSide,
|
||||
statusText,
|
||||
isMyTurn: (v, me) => v.phase === 'playing' && !!v.myColor && v.players[v.turn] === me,
|
||||
Options,
|
||||
|
||||
@@ -18,7 +18,10 @@ export interface OptionsProps<O = unknown> {
|
||||
}
|
||||
|
||||
export interface GameUI<V = any, O = any> {
|
||||
/** The board / table only (center of the game screen). */
|
||||
Board: ComponentType<BoardProps<V>>;
|
||||
/** Players, clocks, action buttons, move history (right panel on PC). */
|
||||
Side: ComponentType<BoardProps<V>>;
|
||||
statusText(view: V, me: string | null, players: Record<string, PublicUser>): string;
|
||||
isMyTurn(view: V, me: string | null): boolean;
|
||||
Options: ComponentType<OptionsProps<O>>;
|
||||
|
||||
Reference in New Issue
Block a user