M2: 오셀로 추가, 게임 공통 E2E, 접근성 검사(axe) 통과
- 오셀로: 8방향 뒤집기, 자동 패스, 종료·점수(빈칸 승자 몫 옵션), 한 수/피셔 시계·자동 착수, 무르기·무승부·기권, 놓을 곳 점·뒤집힐 돌 미리 보기·뒤집기 애니메이션 (테스트 31개) - 카탈로그에 오셀로·체스·장기·바둑 추가(엔진이 등록된 게임만 화면에 나옴) - e2e/games.e2e.ts: PC 방장 + 휴대폰 친구, 게임 소개→방 만들기→링크 입장→시작→차례대로 첫 수 - e2e/a11y.e2e.ts: 홈·게임 소개·대기실 × PC/휴대폰 × 밝게/어둡게, axe 위반 0건 (h1, 랜드마크, 제목 순서 정리) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
618
apps/web/src/games/othello/index.tsx
Normal file
618
apps/web/src/games/othello/index.tsx
Normal file
@@ -0,0 +1,618 @@
|
||||
/** 오셀로 화면 (docs/games/othello.md §9). */
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { OthelloLegalMove, OthelloOptions, OthelloView } from '@bg/games/othello';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { Avatar, ConfirmSheet, Sheet } from '../../components/ui';
|
||||
import { TurnTimer, useServerNow } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import { toast } from '../../store/toast';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
|
||||
type Color = 'black' | 'white';
|
||||
interface Pt {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
const N = 8;
|
||||
const COLOR_KO: Record<Color, string> = { black: '흑', white: '백' };
|
||||
const other = (c: Color): Color => (c === 'black' ? 'white' : 'black');
|
||||
const label = (p: Pt) => `${'abcdefgh'[p.x]}${p.y + 1}`;
|
||||
const FLIP_MS = 240;
|
||||
|
||||
const CSS = `
|
||||
@keyframes oth-flip { 0% { transform: scaleX(1); } 50% { transform: scaleX(0.05); } 100% { transform: scaleX(1); } }
|
||||
@keyframes oth-old { 0%, 50% { opacity: 1; } 50.01%, 100% { opacity: 0; } }
|
||||
@keyframes oth-drop { 0% { transform: scale(0.4); opacity: 0.4; } 100% { transform: scale(1); opacity: 1; } }
|
||||
.oth-flip { transform-box: fill-box; transform-origin: center; animation: oth-flip ${FLIP_MS}ms ease-in-out both; }
|
||||
.oth-old { animation: oth-old ${FLIP_MS}ms linear both; }
|
||||
.oth-drop { transform-box: fill-box; transform-origin: center; animation: oth-drop 160ms ease-out both; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.oth-flip, .oth-drop { animation: none; }
|
||||
.oth-old { display: none; }
|
||||
}`;
|
||||
|
||||
function Disc({ color, cx, cy, r = 0.4, ghost }: { color: Color; cx: number; cy: number; r?: number; ghost?: boolean }) {
|
||||
const black = color === 'black';
|
||||
return (
|
||||
<g opacity={ghost ? 0.5 : 1}>
|
||||
<circle cx={cx + 0.03} cy={cy + 0.05} r={r} fill="#000" opacity={ghost ? 0 : 0.25} />
|
||||
<circle cx={cx} cy={cy} r={r} fill={black ? '#161616' : '#fafaf7'} stroke={black ? '#000' : '#9a9a92'} strokeWidth={0.03} />
|
||||
<circle cx={cx - r * 0.28} cy={cy - r * 0.3} r={r * 0.32} fill="#fff" opacity={black ? 0.14 : 0.6} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
interface Anim {
|
||||
key: number;
|
||||
at: number;
|
||||
from: Color;
|
||||
delay: Map<number, number>;
|
||||
}
|
||||
|
||||
function OthelloBoard({ view: v, act, pending, tapConfirm }: BoardProps<OthelloView>) {
|
||||
const myTurn = v.phase === 'playing' && v.myColor === v.turn && v.legalMoves !== null;
|
||||
const [hover, setHover] = useState<Pt | null>(null);
|
||||
const [preview, setPreview] = useState<Pt | null>(null);
|
||||
const [cursor, setCursor] = useState<Pt>({ x: 3, y: 3 });
|
||||
const [kbd, setKbd] = useState(false);
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const [anim, setAnim] = useState<Anim | null>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const prevCount = useRef(v.moveCount);
|
||||
|
||||
const legal = useMemo(() => {
|
||||
const m = new Map<number, OthelloLegalMove>();
|
||||
for (const l of v.legalMoves ?? []) m.set(l.y * N + l.x, l);
|
||||
return m;
|
||||
}, [v.legalMoves]);
|
||||
|
||||
// Flip animation, sounds, pass toasts and screen-reader text for new moves (before paint, so no flash).
|
||||
useLayoutEffect(() => {
|
||||
const prev = prevCount.current;
|
||||
prevCount.current = v.moveCount;
|
||||
setPreview(null);
|
||||
if (v.moveCount <= prev) {
|
||||
setAnim(null);
|
||||
return;
|
||||
}
|
||||
const added = v.moves.slice(prev);
|
||||
const texts: string[] = [];
|
||||
for (const m of added) {
|
||||
if (m.kind === 'place') texts.push(`${COLOR_KO[m.color]}이 ${label(m)}에 두어 ${m.flipped.length}개를 뒤집었어요.`);
|
||||
else {
|
||||
const msg =
|
||||
m.color === v.myColor
|
||||
? '둘 곳이 없어서 차례가 상대에게 넘어갔어요'
|
||||
: v.myColor
|
||||
? '상대가 둘 곳이 없어서 내 차례가 한 번 더 와요'
|
||||
: `${COLOR_KO[m.color]}이 둘 곳이 없어서 차례가 넘어갔어요`;
|
||||
texts.push(msg + '.');
|
||||
toast(msg);
|
||||
}
|
||||
}
|
||||
setAnnounce(texts.join(' '));
|
||||
const lm = v.lastMove;
|
||||
if (lm && added.some((m) => m.kind === 'place')) {
|
||||
sounds.stone();
|
||||
const dist = (i: number) => Math.max(Math.abs((i % N) - lm.x), Math.abs(Math.floor(i / N) - lm.y));
|
||||
const maxD = Math.max(1, ...lm.flipped.map(dist));
|
||||
const step = maxD > 1 ? Math.min(60, (400 - FLIP_MS) / (maxD - 1)) : 0;
|
||||
setAnim({ key: v.moveCount, at: lm.y * N + lm.x, from: other(lm.color), delay: new Map(lm.flipped.map((i) => [i, (dist(i) - 1) * step])) });
|
||||
} else setAnim(null);
|
||||
if (v.phase === 'playing' && v.myColor === v.turn) {
|
||||
haptic(40);
|
||||
if (document.visibilityState === 'hidden') sounds.myTurn();
|
||||
}
|
||||
}, [v.moveCount]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const canPlace = (p: Pt) => myTurn && !pending && legal.has(p.y * N + p.x);
|
||||
|
||||
const place = (p: Pt) => {
|
||||
act({ type: 'place', x: p.x, y: p.y });
|
||||
setPreview(null);
|
||||
setHover(null);
|
||||
};
|
||||
|
||||
const refuse = (p: Pt) => {
|
||||
const msg = v.board[p.y * N + p.x] !== 0 ? '이미 돌이 있는 칸이에요.' : '여기에 두면 뒤집을 돌이 없어요.';
|
||||
haptic([30, 30, 30]);
|
||||
setAnnounce(msg);
|
||||
toast(msg);
|
||||
};
|
||||
|
||||
const tap = (p: Pt, mouse: boolean) => {
|
||||
setCursor(p);
|
||||
if (!myTurn) return;
|
||||
if (!legal.has(p.y * N + p.x)) {
|
||||
refuse(p);
|
||||
return;
|
||||
}
|
||||
// Mouse: one click (hover already previews). 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);
|
||||
else setPreview(p);
|
||||
};
|
||||
|
||||
const pointToCell = (e: React.PointerEvent<SVGSVGElement>): Pt | null => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return null;
|
||||
const pt = svg.createSVGPoint();
|
||||
pt.x = e.clientX;
|
||||
pt.y = e.clientY;
|
||||
const loc = pt.matrixTransform(svg.getScreenCTM()!.inverse());
|
||||
const x = Math.floor(loc.x);
|
||||
const y = Math.floor(loc.y);
|
||||
return x >= 0 && y >= 0 && x < N && y < N ? { x, y } : null;
|
||||
};
|
||||
|
||||
const onKey = (e: React.KeyboardEvent) => {
|
||||
setKbd(true);
|
||||
const d: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
||||
if (d[e.key]) {
|
||||
e.preventDefault();
|
||||
const next = { x: Math.min(N - 1, Math.max(0, cursor.x + d[e.key]![0])), y: Math.min(N - 1, Math.max(0, cursor.y + d[e.key]![1])) };
|
||||
const l = legal.get(next.y * N + next.x);
|
||||
const cell = v.board[next.y * N + next.x];
|
||||
setCursor(next);
|
||||
setAnnounce(`${label(next)} ${cell === 1 ? '흑' : cell === 2 ? '백' : l && v.showLegalMoves ? `둘 수 있음, ${l.flips}개 뒤집힘` : '빈칸'}`);
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (!myTurn) return;
|
||||
if (canPlace(cursor)) place(cursor);
|
||||
else refuse(cursor);
|
||||
}
|
||||
};
|
||||
|
||||
const target = preview ?? (hover && myTurn ? hover : null);
|
||||
const targetMove = target ? legal.get(target.y * N + target.x) : undefined;
|
||||
const showFlips = !!targetMove && (v.showLegalMoves || !!preview);
|
||||
const flipSet = new Set(showFlips ? targetMove!.flipped : []);
|
||||
const showGhost = !!target && v.myColor && v.board[target.y * N + target.x] === 0 && (!!targetMove || !v.showLegalMoves);
|
||||
const total = v.discs.black + v.discs.white;
|
||||
const pad = 0.55;
|
||||
|
||||
return (
|
||||
<div className="board-wrap stack" style={{ gap: 10 }}>
|
||||
<style>{CSS}</style>
|
||||
<div
|
||||
aria-label={`돌 수: 흑 ${v.discs.black}개, 백 ${v.discs.white}개`}
|
||||
role="img"
|
||||
style={{ display: 'flex', height: 30, borderRadius: 15, overflow: 'hidden', border: '2px solid var(--border)', fontWeight: 800, fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
<div style={{ flex: `${v.discs.black} 1 0`, minWidth: 48, background: '#161616', color: '#fff', display: 'flex', alignItems: 'center', paddingLeft: 12, transition: 'flex-grow 0.4s' }}>
|
||||
● {v.discs.black}
|
||||
</div>
|
||||
<div
|
||||
style={{ flex: `${total ? v.discs.white : 1} 1 0`, minWidth: 48, background: '#fafaf7', color: '#161616', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', paddingRight: 12, transition: 'flex-grow 0.4s' }}
|
||||
>
|
||||
{v.discs.white} ○
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`${-pad} ${-pad} ${N + 2 * pad} ${N + 2 * pad}`}
|
||||
style={{ width: '100%', display: 'block', background: '#123d2b', borderRadius: 14, 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={`오셀로 판 8×8. 방향키로 움직이고 Enter로 둡니다. 현재 위치 ${label(cursor)}`}
|
||||
onKeyDown={onKey}
|
||||
>
|
||||
<rect x={0} y={0} width={N} height={N} fill="var(--felt)" />
|
||||
{Array.from({ length: N + 1 }, (_, i) => (
|
||||
<g key={i} stroke="#0b2a1d" strokeWidth={i === 0 || i === N ? 0.05 : 0.03} opacity={0.75}>
|
||||
<line x1={0} y1={i} x2={N} y2={i} />
|
||||
<line x1={i} y1={0} x2={i} y2={N} />
|
||||
</g>
|
||||
))}
|
||||
{[2, 6].flatMap((x) => [2, 6].map((y) => <circle key={`s${x}${y}`} cx={x} cy={y} r={0.07} fill="#0b2a1d" opacity={0.8} />))}
|
||||
{Array.from({ length: N }, (_, i) => (
|
||||
<g key={`c${i}`} fill="#d7eadf" fontSize={0.3} fontWeight={700} textAnchor="middle" dominantBaseline="central">
|
||||
<text x={i + 0.5} y={-0.28}>
|
||||
{'abcdefgh'[i]}
|
||||
</text>
|
||||
<text x={-0.28} y={i + 0.5}>
|
||||
{i + 1}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{myTurn &&
|
||||
v.showLegalMoves &&
|
||||
[...legal.values()].map((l) =>
|
||||
v.showFlipCount ? (
|
||||
<g key={`l${l.x}-${l.y}`}>
|
||||
<circle cx={l.x + 0.5} cy={l.y + 0.5} r={0.2} fill={v.myColor === 'black' ? 'rgba(0,0,0,0.4)' : 'rgba(255,255,255,0.6)'} />
|
||||
<text x={l.x + 0.5} y={l.y + 0.52} fontSize={0.26} fontWeight={800} textAnchor="middle" dominantBaseline="central" fill={v.myColor === 'black' ? '#fff' : '#111'}>
|
||||
{l.flips}
|
||||
</text>
|
||||
</g>
|
||||
) : (
|
||||
<circle key={`l${l.x}-${l.y}`} cx={l.x + 0.5} cy={l.y + 0.5} r={0.14} fill={v.myColor === 'black' ? 'rgba(0,0,0,0.4)' : 'rgba(255,255,255,0.6)'} />
|
||||
),
|
||||
)}
|
||||
{v.board.map((c, i) => {
|
||||
if (c === 0) return null;
|
||||
const color: Color = c === 1 ? 'black' : 'white';
|
||||
const cx = (i % N) + 0.5;
|
||||
const cy = Math.floor(i / N) + 0.5;
|
||||
const delay = anim?.delay.get(i);
|
||||
if (anim && delay !== undefined) {
|
||||
return (
|
||||
<g key={`a${anim.key}-${i}`} className="oth-flip" style={{ animationDelay: `${delay}ms` }}>
|
||||
<Disc color={color} cx={cx} cy={cy} />
|
||||
<g className="oth-old" style={{ animationDelay: `${delay}ms` }}>
|
||||
<Disc color={anim.from} cx={cx} cy={cy} />
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
if (anim && anim.at === i) {
|
||||
return (
|
||||
<g key={`a${anim.key}-${i}`} className="oth-drop">
|
||||
<Disc color={color} cx={cx} cy={cy} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
return <Disc key={i} color={color} cx={cx} cy={cy} />;
|
||||
})}
|
||||
{[...flipSet].map((i) => (
|
||||
<circle key={`h${i}`} cx={(i % N) + 0.5} cy={Math.floor(i / N) + 0.5} r={0.44} fill="rgba(255, 224, 102, 0.45)" stroke="#ffd84d" strokeWidth={0.05} />
|
||||
))}
|
||||
{v.lastMove && <circle cx={v.lastMove.x + 0.5} cy={v.lastMove.y + 0.5} r={0.1} fill="#ef4444" stroke="#fff" strokeWidth={0.025} />}
|
||||
{showGhost && target && v.myColor && <Disc color={v.myColor} cx={target.x + 0.5} cy={target.y + 0.5} ghost />}
|
||||
{myTurn && kbd && <rect x={cursor.x + 0.04} y={cursor.y + 0.04} width={0.92} height={0.92} rx={0.08} fill="none" stroke="var(--focus)" strokeWidth={0.07} />}
|
||||
</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(preview)}에 두기${targetMove ? ` (${targetMove.flips}개 뒤집기)` : ''}`}
|
||||
</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 OthelloSide({ view: v, me, players, act, pending, deadline }: BoardProps<OthelloView>) {
|
||||
const [confirm, setConfirm] = useState<null | 'resign' | 'draw'>(null);
|
||||
const incoming =
|
||||
v.phase === 'playing' && v.myColor
|
||||
? v.undo.pending && v.undo.pending.by !== v.myColor
|
||||
? 'undo'
|
||||
: v.draw.pendingBy && v.draw.pendingBy !== v.myColor
|
||||
? 'draw'
|
||||
: null
|
||||
: 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 top: Color = v.myColor ? other(v.myColor) : 'black';
|
||||
const bottom: Color = v.myColor ?? 'white';
|
||||
const undoPlaces = v.undo.pending ? v.moves.slice(v.moves.length - v.undo.pending.plies).filter((m) => m.kind === 'place').length : 0;
|
||||
return (
|
||||
<>
|
||||
<PlayerBar view={v} color={top} players={players} deadline={deadline} me={me} />
|
||||
<PlayerBar view={v} color={bottom} players={players} deadline={deadline} me={me} />
|
||||
{v.phase === 'playing' && v.myColor && (
|
||||
<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.kind === 'place' && m.color === v.myColor)}
|
||||
onClick={() => act({ type: 'requestUndo' })}
|
||||
title={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')}>
|
||||
🏳 기권
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{outgoing && (
|
||||
<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' ? '상대가 무르기를 요청했어요' : '상대가 무승부를 제안했어요'}</h2>
|
||||
<p className="muted">
|
||||
{incoming === 'undo' ? `수락하면 상대가 마지막으로 둔 수 전으로 돌아가요(돌 ${undoPlaces}수).` : '수락하면 이 판은 무승부로 끝나요.'}
|
||||
</p>
|
||||
<div className="row">
|
||||
<button className="btn btn-primary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: true } : { type: 'respondDraw', accept: true })}>
|
||||
수락
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: false } : { type: 'respondDraw', accept: false })}>
|
||||
거절
|
||||
</button>
|
||||
</div>
|
||||
</Sheet>
|
||||
)}
|
||||
{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)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MoveList({ view: v }: { view: OthelloView }) {
|
||||
const ref = useRef<HTMLOListElement>(null);
|
||||
useEffect(() => {
|
||||
ref.current?.scrollTo({ top: ref.current.scrollHeight });
|
||||
}, [v.moveCount]);
|
||||
if (v.moveCount === 0) return null;
|
||||
let n = 0;
|
||||
return (
|
||||
<details className="card wide-open" open style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기보 ({v.moves.filter((m) => m.kind === 'place').length}수)</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">
|
||||
{m.kind === 'place' ? (
|
||||
<>
|
||||
<span className="muted">{++n}.</span> {m.color === 'black' ? '●' : '○'} {label(m)}
|
||||
{m.auto ? '*' : ''}
|
||||
</>
|
||||
) : (
|
||||
<span className="muted">
|
||||
{m.color === 'black' ? '●' : '○'} 패스
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayerBar({ view: v, color, players, deadline, me }: { view: OthelloView; color: Color; players: Record<string, PublicUser>; deadline: number | null; me: string | null }) {
|
||||
const id = v.players[color];
|
||||
const u = players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' as const };
|
||||
const active = v.phase === 'playing' && v.turn === color;
|
||||
const now = useServerNow(500);
|
||||
const tc = v.timeControl;
|
||||
let clock: React.ReactNode = null;
|
||||
if (tc.kind === 'fischer') {
|
||||
const ms = Math.max(0, v.remainingMs[color] - (active ? now - v.turnStartedAt : 0));
|
||||
const s = Math.ceil(ms / 1000);
|
||||
clock = (
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 700, fontSize: '1.15rem', color: active && s <= 10 ? 'var(--danger)' : undefined }}>
|
||||
{Math.floor(s / 60)}:{String(s % 60).padStart(2, '0')}
|
||||
</span>
|
||||
);
|
||||
} else if (active) {
|
||||
const total = tc.kind === 'perMove' ? tc.perMoveSec * 1000 : deadline ? Math.max(1000, deadline - v.turnStartedAt) : 300_000;
|
||||
clock = <TurnTimer deadline={deadline} totalMs={total} mine={id === me} />;
|
||||
}
|
||||
const score = v.result ? v.result.displayScore[color] : v.discs[color];
|
||||
return (
|
||||
<div className="card row" style={{ padding: '8px 12px', gap: 10, border: `3px solid ${active ? 'var(--primary)' : 'transparent'}`, transition: 'border-color 0.2s' }}>
|
||||
<svg width={28} height={28} viewBox="-0.5 -0.5 1 1" aria-hidden>
|
||||
<Disc color={color} cx={0} cy={0} r={0.45} />
|
||||
</svg>
|
||||
<Avatar user={u} size={32} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{u.nickname}
|
||||
{id === me && <span className="muted small"> (나)</span>}
|
||||
</div>
|
||||
<div className="muted small">
|
||||
{COLOR_KO[color]}
|
||||
{active ? ' · 두는 중' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span aria-label={`${COLOR_KO[color]} 돌 ${score}개`} style={{ fontWeight: 800, fontSize: '1.4rem', fontVariantNumeric: 'tabular-nums', minWidth: '2ch', textAlign: 'right' }}>
|
||||
{score}
|
||||
</span>
|
||||
{clock}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusText(v: OthelloView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
|
||||
if (v.myColor && v.undo.pending && v.undo.pending.by !== v.myColor) return '상대가 무르기를 요청했어요';
|
||||
if (v.myColor && v.draw.pendingBy && v.draw.pendingBy !== v.myColor) return '상대가 무승부를 제안했어요';
|
||||
if (v.myColor === v.turn) {
|
||||
return v.showLegalMoves ? `내 차례예요 (${COLOR_KO[v.turn]}). 점이 있는 칸에 둘 수 있어요` : `내 차례예요 (${COLOR_KO[v.turn]}). 상대 돌을 끼울 수 있는 칸에 둬요`;
|
||||
}
|
||||
const nick = players[v.players[v.turn]]?.nickname ?? COLOR_KO[v.turn];
|
||||
return v.myColor ? `${nick}님이 생각 중이에요…` : `${nick}님(${COLOR_KO[v.turn]}) 차례예요`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- options
|
||||
|
||||
function Seg<T extends string | number>({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) {
|
||||
return (
|
||||
<div className="segmented">
|
||||
{options.map(([k, l]) => (
|
||||
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TIME_PRESETS: [string, string, Partial<OthelloOptions['timeControl']>][] = [
|
||||
['pm10', '한 수 10초', { kind: 'perMove', perMoveSec: 10 }],
|
||||
['pm20', '한 수 20초', { kind: 'perMove', perMoveSec: 20 }],
|
||||
['pm30', '한 수 30초', { kind: 'perMove', perMoveSec: 30 }],
|
||||
['pm60', '한 수 60초', { kind: 'perMove', perMoveSec: 60 }],
|
||||
['f3', '3분 + 2초', { kind: 'fischer', baseSec: 180, incSec: 2 }],
|
||||
['f5', '5분 + 3초', { kind: 'fischer', baseSec: 300, incSec: 3 }],
|
||||
['f10', '10분 + 5초', { kind: 'fischer', baseSec: 600, incSec: 5 }],
|
||||
['none', '시간 제한 없음', { kind: 'none' }],
|
||||
];
|
||||
|
||||
function timeKey(tc: OthelloOptions['timeControl']): string {
|
||||
if (tc.kind === 'none') return 'none';
|
||||
if (tc.kind === 'perMove') return `pm${tc.perMoveSec}`;
|
||||
return `f${Math.round(tc.baseSec / 60)}`;
|
||||
}
|
||||
|
||||
function timeLabel(tc: OthelloOptions['timeControl']): string {
|
||||
if (tc.kind === 'none') return '시간 제한 없음';
|
||||
if (tc.kind === 'perMove') return `한 수 ${tc.perMoveSec}초`;
|
||||
const m = Math.floor(tc.baseSec / 60);
|
||||
const sec = tc.baseSec % 60;
|
||||
return `${m}분${sec ? ` ${sec}초` : ''} + ${tc.incSec}초`;
|
||||
}
|
||||
|
||||
function Options({ value: o, onChange }: OptionsProps<OthelloOptions>) {
|
||||
const [more, setMore] = useState(false);
|
||||
const set = (patch: Partial<OthelloOptions>) => onChange({ ...o, ...patch });
|
||||
const onOff = (v: boolean) => (v ? 'on' : 'off');
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>시간</strong>
|
||||
<Seg
|
||||
value={timeKey(o.timeControl)}
|
||||
options={[
|
||||
...TIME_PRESETS.map(([k, l]) => [k, l] as [string, string]),
|
||||
...(TIME_PRESETS.some((p) => p[0] === timeKey(o.timeControl)) ? [] : [[timeKey(o.timeControl), `${timeLabel(o.timeControl)}(관리자 설정)`] as [string, string]]),
|
||||
]}
|
||||
onChange={(k) => {
|
||||
const preset = TIME_PRESETS.find((p) => p[0] === k);
|
||||
if (preset) set({ timeControl: { ...o.timeControl, ...preset[2] } });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>흑(먼저 두는 쪽)</strong>
|
||||
<Seg
|
||||
value={o.colorAssignment}
|
||||
options={[['random', '무작위'], ['hostBlack', '방장이 흑'], ['hostWhite', '방장이 백'], ['alternate', '판마다 번갈아']]}
|
||||
onChange={(colorAssignment) => set({ colorAssignment })}
|
||||
/>
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>둘 수 있는 칸</strong>
|
||||
<Seg value={onOff(o.showLegalMoves)} options={[['on', '점으로 보여 주기'], ['off', '보여 주지 않기']]} onChange={(k) => set({ showLegalMoves: k === 'on' })} />
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
|
||||
</button>
|
||||
{more && (
|
||||
<>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>뒤집히는 개수 숫자</strong>
|
||||
<Seg value={onOff(o.showFlipCount)} options={[['on', '보여 주기(초보 도움)'], ['off', '안 보여 주기']]} onChange={(k) => set({ showFlipCount: k === 'on' })} />
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>점수 계산</strong>
|
||||
<Seg value={onOff(o.emptyToWinner)} options={[['off', '돌 개수 그대로'], ['on', '남은 빈칸은 이긴 쪽에(대회 방식)']]} onChange={(k) => set({ emptyToWinner: k === 'on' })} />
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>무르기</strong>
|
||||
<Seg value={o.undo} options={[['off', '안 씀'], ['1', '1번'], ['3', '3번'], ['unlimited', '무제한']]} onChange={(undo) => set({ undo })} />
|
||||
</div>
|
||||
{o.timeControl.kind === 'perMove' && (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>시간이 다 되면</strong>
|
||||
<Seg value={o.timeoutPolicy} options={[['autoMove', '자동으로 두기'], ['lose', '시간패']]} onChange={(timeoutPolicy) => set({ timeoutPolicy })} />
|
||||
</div>
|
||||
)}
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>무승부 제안</strong>
|
||||
<Seg value={onOff(o.drawOffer)} options={[['on', '허용'], ['off', '안 함']]} onChange={(k) => set({ drawOffer: k === 'on' })} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function optionsSummary(o: OthelloOptions): string {
|
||||
return ['8×8', timeLabel(o.timeControl), o.showLegalMoves ? '둘 곳 표시' : '둘 곳 숨김', ...(o.emptyToWinner ? ['대회 점수'] : [])].join(' · ');
|
||||
}
|
||||
|
||||
/** One-row diagram for the rules: cells are 'B' | 'W' | '.' with optional highlighted cell. */
|
||||
function RowDiagram({ cells, mark, caption }: { cells: string; mark?: number; caption: string }) {
|
||||
return (
|
||||
<figure style={{ margin: 0, textAlign: 'center' }}>
|
||||
<svg viewBox={`0 0 ${cells.length} 1`} style={{ width: '100%', maxWidth: 220, display: 'block', margin: '0 auto', background: 'var(--felt)', borderRadius: 6 }} aria-hidden>
|
||||
{cells.split('').map((c, i) => (
|
||||
<g key={i}>
|
||||
<rect x={i} y={0} width={1} height={1} fill="none" stroke="#0b2a1d" strokeWidth={0.04} />
|
||||
{c !== '.' && <Disc color={c === 'B' ? 'black' : 'white'} cx={i + 0.5} cy={0.5} r={0.38} />}
|
||||
</g>
|
||||
))}
|
||||
{mark !== undefined && <circle cx={mark + 0.5} cy={0.5} r={0.1} fill="#ef4444" stroke="#fff" strokeWidth={0.025} />}
|
||||
</svg>
|
||||
<figcaption className="small muted" style={{ marginTop: 4 }}>
|
||||
{caption}
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
function Rules({ options }: { options?: OthelloOptions }) {
|
||||
return (
|
||||
<div className="stack">
|
||||
<p>
|
||||
흑과 백이 번갈아 8×8 판의 빈칸에 돌을 하나씩 놓아요. 흑이 먼저 둬요. 오셀로와 같은 규칙이에요. 게임이 끝났을 때 <strong>내 색 돌이 더 많으면</strong> 이겨요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>끼워서 뒤집기</strong>: 상대 돌을 내 돌 두 개 사이에 끼우면, 끼인 상대 돌이 모두 내 색으로 뒤집혀요. 가로, 세로, 대각선 어느 방향이든 돼요.
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 12 }}>
|
||||
<RowDiagram cells="BWW." caption="① 흑 돌 옆에 백 돌이 있어요" />
|
||||
<RowDiagram cells="BWWB" mark={3} caption="② 끝에 흑을 놓아 백을 끼워요" />
|
||||
<RowDiagram cells="BBBB" mark={3} caption="③ 끼인 백이 흑으로 뒤집혀요" />
|
||||
</div>
|
||||
<p>상대 돌을 하나도 뒤집을 수 없는 칸에는 둘 수 없어요. 뒤집을 수 있는 칸이 있으면 꼭 그중 한 곳에 둬야 해요.</p>
|
||||
<p>
|
||||
<strong>패스</strong>: 둘 곳이 하나도 없으면 자동으로 차례가 상대에게 넘어가요. 누를 것은 없어요. 둘 다 둘 곳이 없으면(판이 가득 차도) 게임이 끝나요.
|
||||
</p>
|
||||
{options?.emptyToWinner && <p>이 방은 대회 방식이라, 빈칸이 남은 채 끝나면 남은 빈칸 수를 이긴 쪽 점수에 더해요(승패는 그대로예요).</p>}
|
||||
<p>판의 작은 점은 지금 둘 수 있는 칸이에요(설정에서 끌 수 있어요). PC에서는 마우스를 올리면 뒤집힐 돌이 노랗게 보이고, 한 번 누르면 놓여요.</p>
|
||||
<p className="muted">팁: 네 귀퉁이(모서리)에 놓은 돌은 다시 뒤집히지 않아서 아주 유리해요.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ui: GameUI<OthelloView, OthelloOptions> = {
|
||||
Board: OthelloBoard,
|
||||
Side: OthelloSide,
|
||||
statusText,
|
||||
isMyTurn: (v, me) => v.phase === 'playing' && !!v.myColor && v.players[v.turn] === me,
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
@@ -3,6 +3,7 @@ import type { GameUI } from './types';
|
||||
/** Game screens are code-split: each is downloaded only when that game is opened (docs/09 §6). */
|
||||
export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
|
||||
omok: () => import('./omok'),
|
||||
othello: () => import('./othello'),
|
||||
};
|
||||
|
||||
const cache = new Map<string, GameUI>();
|
||||
|
||||
Reference in New Issue
Block a user