feat: add chess resign and undo actions
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m16s
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m16s
This commit is contained in:
12
LOG.md
12
LOG.md
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
## 2026-06-19
|
## 2026-06-19
|
||||||
|
|
||||||
|
- Added Maia game resign and undo API wrappers, then wired the play screen with in-progress-only "무르기" and "기권" actions plus a persistent "새 게임" link back to the setup screen.
|
||||||
|
- Locked board/actions during move, undo, and resign requests, updated the game cache from backend responses as the single source of truth, and refreshed history/stat queries after successful state changes.
|
||||||
|
- Added resigned-loss labeling so play, recent games, and history can show `기권패` for `RESIGNED` losses while still grouping them under losses.
|
||||||
|
- Validation: `npm run lint` passed and `npm run build` passed. Authenticated browser verification against the live Maia backend was not run in this session.
|
||||||
|
- Recommended next task: test resign and undo with an authenticated real game, including black-player games where Maia's opening white move must remain after undo.
|
||||||
|
|
||||||
|
- Reworked the shared chess board UI with a bordered board surface, high-contrast chip-style pieces, stronger selected/legal/last-move highlights, and transform-based piece movement transitions so player and Maia moves read as motion instead of instant jumps.
|
||||||
|
- Connected the daily puzzle board to the shared `ChessBoard` component, removing duplicated piece rendering and adding drag/drop support for puzzle moves.
|
||||||
|
- Updated Maia play state so optimistic player moves are highlighted locally while pending, then Maia's returned move can animate from the server response; changed the pending overlay into a small status pill so it no longer hides the board.
|
||||||
|
- Validation: `npm run lint` passed and `npm run build` passed. A local dev server is running on `http://localhost:3124`; browser verification with a temporary local puzzle API confirmed 64 squares, 32 piece-layer nodes, 0.42s piece transition styling, square selection/legal target markers, and no console errors. The temporary API was stopped after verification.
|
||||||
|
- Recommended next task: verify `/chess/play/{gameId}` against the real Maia backend with an authenticated account to tune the exact Maia response timing and capture/promotion edge cases.
|
||||||
|
|
||||||
- Added the authenticated Maia chess frontend: `/chess` lobby with rating/color/model and advanced settings, `/chess/play/[gameId]` playable board with backend FEN synchronization and PGN copy, and `/chess/history` account game history with outcome filters.
|
- Added the authenticated Maia chess frontend: `/chess` lobby with rating/color/model and advanced settings, `/chess/play/[gameId]` playable board with backend FEN synchronization and PGN copy, and `/chess/history` account game history with outcome filters.
|
||||||
- Extended the chess API client and shared types for Maia game create/list/stats/detail/PGN/move endpoints while leaving the public `/api/chess-puzzles/**` puzzle request unchanged.
|
- Extended the chess API client and shared types for Maia game create/list/stats/detail/PGN/move endpoints while leaving the public `/api/chess-puzzles/**` puzzle request unchanged.
|
||||||
- Updated the Dock/menu/shell route handling so Chess opens the Maia lobby, with the existing public puzzle page still available at `/play/chess`.
|
- Updated the Dock/menu/shell route handling so Chess opens the Maia lobby, with the existing public puzzle page still available at `/play/chess`.
|
||||||
|
|||||||
@@ -71,3 +71,15 @@ export const postChessMove = async (gameId: string, data: ChessMoveRequest) => {
|
|||||||
|
|
||||||
return requireApiData(response.data, '착수를 처리하지 못했습니다.');
|
return requireApiData(response.data, '착수를 처리하지 못했습니다.');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const resignChessGame = async (gameId: string) => {
|
||||||
|
const response = await http.post<ApiResponse<ChessGameResponse>>(`/api/chess/games/${gameId}/resign`);
|
||||||
|
|
||||||
|
return requireApiData(response.data, '기권을 처리하지 못했습니다.');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const undoChessMove = async (gameId: string) => {
|
||||||
|
const response = await http.post<ApiResponse<ChessGameResponse>>(`/api/chess/games/${gameId}/undo`);
|
||||||
|
|
||||||
|
return requireApiData(response.data, '무르기를 처리하지 못했습니다.');
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||||
import { Chess as ChessGame, type Color, type PieceSymbol, type Square } from 'chess.js';
|
import { Chess as ChessGame, type Color, type PieceSymbol, type Square } from 'chess.js';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import { ChessColor } from '@/types';
|
import { ChessColor } from '@/types';
|
||||||
|
|
||||||
type MoveSquares = {
|
export type MoveSquares = {
|
||||||
from: Square;
|
from: Square;
|
||||||
to: Square;
|
to: Square;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BoardPiece = {
|
||||||
|
square: Square;
|
||||||
|
color: Color;
|
||||||
|
type: PieceSymbol;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VisualPiece = BoardPiece & {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
interface ChessBoardProps {
|
interface ChessBoardProps {
|
||||||
fen: string;
|
fen: string;
|
||||||
orientation?: ChessColor;
|
orientation?: ChessColor;
|
||||||
@@ -26,6 +36,10 @@ const FILES = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] as const;
|
|||||||
const WHITE_RANKS = [8, 7, 6, 5, 4, 3, 2, 1] as const;
|
const WHITE_RANKS = [8, 7, 6, 5, 4, 3, 2, 1] as const;
|
||||||
const BLACK_RANKS = [1, 2, 3, 4, 5, 6, 7, 8] as const;
|
const BLACK_RANKS = [1, 2, 3, 4, 5, 6, 7, 8] as const;
|
||||||
|
|
||||||
|
export const BOARD_SQUARES = WHITE_RANKS.flatMap((rank) =>
|
||||||
|
FILES.map((file) => `${file}${rank}` as Square),
|
||||||
|
);
|
||||||
|
|
||||||
const PIECE_SYMBOLS: Record<Color, Record<PieceSymbol, string>> = {
|
const PIECE_SYMBOLS: Record<Color, Record<PieceSymbol, string>> = {
|
||||||
w: {
|
w: {
|
||||||
k: '♔',
|
k: '♔',
|
||||||
@@ -71,6 +85,15 @@ const getBoardSquares = (orientation: ChessColor) => {
|
|||||||
return ranks.flatMap((rank) => files.map((file) => `${file}${rank}` as Square));
|
return ranks.flatMap((rank) => files.map((file) => `${file}${rank}` as Square));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getPieces = (game: ChessGame): BoardPiece[] =>
|
||||||
|
BOARD_SQUARES.flatMap((square) => {
|
||||||
|
const piece = game.get(square);
|
||||||
|
|
||||||
|
if (!piece) return [];
|
||||||
|
|
||||||
|
return [{ square, color: piece.color, type: piece.type }];
|
||||||
|
});
|
||||||
|
|
||||||
const getSquareLabel = (square: Square, piece?: { color: Color; type: PieceSymbol }) => {
|
const getSquareLabel = (square: Square, piece?: { color: Color; type: PieceSymbol }) => {
|
||||||
if (!piece) return `${square} 빈 칸`;
|
if (!piece) return `${square} 빈 칸`;
|
||||||
|
|
||||||
@@ -84,6 +107,73 @@ const isLightSquare = (square: Square) => {
|
|||||||
return (fileIndex + rank) % 2 === 0;
|
return (fileIndex + rank) % 2 === 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getSquarePosition = (square: Square, orientation: ChessColor): CSSProperties => {
|
||||||
|
const fileIndex = FILES.indexOf(square[0] as (typeof FILES)[number]);
|
||||||
|
const rank = Number(square[1]);
|
||||||
|
const x = orientation === 'black' ? 7 - fileIndex : fileIndex;
|
||||||
|
const y = orientation === 'black' ? rank - 1 : 8 - rank;
|
||||||
|
|
||||||
|
return {
|
||||||
|
transform: `translate(${x * 100}%, ${y * 100}%)`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const samePiece = (a: BoardPiece, b: BoardPiece) => a.color === b.color && a.type === b.type;
|
||||||
|
|
||||||
|
const canRepresentMove = (fromPiece: VisualPiece, toPiece: BoardPiece) =>
|
||||||
|
fromPiece.color === toPiece.color && (fromPiece.type === toPiece.type || fromPiece.type === 'p');
|
||||||
|
|
||||||
|
const createPieceId = (piece: BoardPiece, index: number) =>
|
||||||
|
`${piece.color}-${piece.type}-${piece.square}-${index}`;
|
||||||
|
|
||||||
|
const createVisualPieces = (pieces: BoardPiece[]) => pieces.map((piece, index) => ({ ...piece, id: createPieceId(piece, index) }));
|
||||||
|
|
||||||
|
const reconcileVisualPieces = (
|
||||||
|
previousPieces: VisualPiece[],
|
||||||
|
nextPieces: BoardPiece[],
|
||||||
|
lastMoveSquares?: MoveSquares | null,
|
||||||
|
) => {
|
||||||
|
if (!lastMoveSquares) return createVisualPieces(nextPieces);
|
||||||
|
|
||||||
|
const nextBySquare = new Map(nextPieces.map((piece) => [piece.square, piece]));
|
||||||
|
const movedPieceTarget = nextBySquare.get(lastMoveSquares.to);
|
||||||
|
|
||||||
|
if (!movedPieceTarget) return createVisualPieces(nextPieces);
|
||||||
|
|
||||||
|
const usedVisualIds = new Set<string>();
|
||||||
|
const usedSquares = new Set<Square>();
|
||||||
|
const reconciled: VisualPiece[] = [];
|
||||||
|
const movingPiece =
|
||||||
|
previousPieces.find(
|
||||||
|
(piece) => piece.square === lastMoveSquares.from && canRepresentMove(piece, movedPieceTarget),
|
||||||
|
) ?? previousPieces.find((piece) => piece.square === lastMoveSquares.from);
|
||||||
|
|
||||||
|
if (movingPiece) {
|
||||||
|
reconciled.push({ ...movingPiece, ...movedPieceTarget });
|
||||||
|
usedVisualIds.add(movingPiece.id);
|
||||||
|
usedSquares.add(movedPieceTarget.square);
|
||||||
|
}
|
||||||
|
|
||||||
|
previousPieces.forEach((visualPiece) => {
|
||||||
|
if (usedVisualIds.has(visualPiece.id)) return;
|
||||||
|
|
||||||
|
const nextPiece = nextBySquare.get(visualPiece.square);
|
||||||
|
if (!nextPiece || usedSquares.has(nextPiece.square) || !samePiece(visualPiece, nextPiece)) return;
|
||||||
|
|
||||||
|
reconciled.push({ ...visualPiece, ...nextPiece });
|
||||||
|
usedVisualIds.add(visualPiece.id);
|
||||||
|
usedSquares.add(nextPiece.square);
|
||||||
|
});
|
||||||
|
|
||||||
|
nextPieces.forEach((nextPiece, index) => {
|
||||||
|
if (usedSquares.has(nextPiece.square)) return;
|
||||||
|
|
||||||
|
reconciled.push({ ...nextPiece, id: createPieceId(nextPiece, previousPieces.length + index) });
|
||||||
|
});
|
||||||
|
|
||||||
|
return reconciled;
|
||||||
|
};
|
||||||
|
|
||||||
export const getMoveSquares = (move?: string | null): MoveSquares | null => {
|
export const getMoveSquares = (move?: string | null): MoveSquares | null => {
|
||||||
if (!move || move.length < 4) return null;
|
if (!move || move.length < 4) return null;
|
||||||
|
|
||||||
@@ -112,104 +202,146 @@ export default function ChessBoard({
|
|||||||
const boardSquares = useMemo(() => getBoardSquares(orientation), [orientation]);
|
const boardSquares = useMemo(() => getBoardSquares(orientation), [orientation]);
|
||||||
const firstFile = orientation === 'black' ? 'h' : 'a';
|
const firstFile = orientation === 'black' ? 'h' : 'a';
|
||||||
const lastRank = orientation === 'black' ? '8' : '1';
|
const lastRank = orientation === 'black' ? '8' : '1';
|
||||||
|
const [visualPieces, setVisualPieces] = useState(() => createVisualPieces(getPieces(game)));
|
||||||
|
const previousFenRef = useRef(fen);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextPieces = getPieces(game);
|
||||||
|
|
||||||
|
setVisualPieces((previousPieces) => {
|
||||||
|
if (previousFenRef.current === fen) return previousPieces;
|
||||||
|
|
||||||
|
previousFenRef.current = fen;
|
||||||
|
return reconcileVisualPieces(previousPieces, nextPieces, lastMoveSquares);
|
||||||
|
});
|
||||||
|
}, [fen, game, lastMoveSquares]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid aspect-square w-full grid-cols-8 overflow-hidden rounded-lg border border-[var(--color-line)] shadow-[var(--shadow-control)]">
|
<div className="relative aspect-square w-full rounded-xl border border-[#3f2d1f]/25 bg-[#4b3828] p-1.5 shadow-[0_20px_42px_rgb(49_35_24_/_0.22),0_1px_0_rgb(255_255_255_/_0.25)_inset] sm:p-2 dark:border-white/10 dark:bg-[#2a241f]">
|
||||||
{boardSquares.map((square) => {
|
<div className="relative h-full w-full overflow-hidden rounded-lg border border-black/25 bg-[#1f2a24] shadow-[0_1px_0_rgb(255_255_255_/_0.18)_inset]">
|
||||||
const piece = game.get(square);
|
<div className="grid h-full w-full grid-cols-[repeat(8,minmax(0,1fr))] grid-rows-[repeat(8,minmax(0,1fr))]">
|
||||||
const isLight = isLightSquare(square);
|
{boardSquares.map((square) => {
|
||||||
const isSelected = selectedSquare === square;
|
const piece = game.get(square);
|
||||||
const isTarget = legalTargets?.has(square) ?? false;
|
const isLight = isLightSquare(square);
|
||||||
const isLastMoveSquare = lastMoveSquares?.from === square || lastMoveSquares?.to === square;
|
const isSelected = selectedSquare === square;
|
||||||
const canDrag = !disabled && Boolean(piece) && (isDraggableSquare?.(square) ?? true);
|
const isTarget = legalTargets?.has(square) ?? false;
|
||||||
const file = square[0];
|
const isLastMoveSquare = lastMoveSquares?.from === square || lastMoveSquares?.to === square;
|
||||||
const rank = square[1];
|
const canDrag = !disabled && Boolean(piece) && (isDraggableSquare?.(square) ?? true);
|
||||||
|
const file = square[0];
|
||||||
|
const rank = square[1];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={square}
|
key={square}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSquareClick?.(square)}
|
onClick={() => onSquareClick?.(square)}
|
||||||
draggable={canDrag}
|
draggable={canDrag}
|
||||||
onDragStart={(event) => {
|
onDragStart={(event) => {
|
||||||
if (!canDrag) {
|
if (!canDrag) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
event.dataTransfer.setData('text/plain', square);
|
event.dataTransfer.setData('text/plain', square);
|
||||||
}}
|
}}
|
||||||
onDragOver={(event) => {
|
onDragOver={(event) => {
|
||||||
if (!disabled && onSquareDrop) {
|
if (!disabled && onSquareDrop) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.dataTransfer.dropEffect = 'move';
|
event.dataTransfer.dropEffect = 'move';
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDrop={(event) => {
|
onDrop={(event) => {
|
||||||
if (disabled || !onSquareDrop) return;
|
if (disabled || !onSquareDrop) return;
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const from = event.dataTransfer.getData('text/plain') as Square;
|
const from = event.dataTransfer.getData('text/plain') as Square;
|
||||||
if (!from || from === square) return;
|
if (!from || from === square) return;
|
||||||
|
|
||||||
onSquareDrop(from, square);
|
onSquareDrop(from, square);
|
||||||
}}
|
}}
|
||||||
className={clsx(
|
|
||||||
'relative flex aspect-square items-center justify-center overflow-hidden text-[2rem] leading-none transition sm:text-[2.5rem] md:text-[3.5rem]',
|
|
||||||
isLight ? 'bg-[#eef0e6]' : 'bg-[#5f8d68]',
|
|
||||||
isSelected && 'z-10 ring-4 ring-[var(--color-accent)] ring-inset',
|
|
||||||
isLastMoveSquare && 'bg-[var(--color-accent-soft)]',
|
|
||||||
!disabled && 'hover:brightness-105 focus-visible:z-20',
|
|
||||||
disabled && 'cursor-not-allowed',
|
|
||||||
)}
|
|
||||||
aria-label={getSquareLabel(square, piece)}
|
|
||||||
aria-pressed={isSelected}
|
|
||||||
>
|
|
||||||
{file === firstFile && (
|
|
||||||
<span
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'absolute left-1.5 top-1 text-[10px] font-bold leading-none',
|
'relative min-h-0 min-w-0 overflow-hidden transition-[filter,box-shadow] duration-150',
|
||||||
isLight ? 'text-[#5f8d68]' : 'text-[#eef0e6]',
|
isLight ? 'bg-[#f0dfc1]' : 'bg-[#6f8f72]',
|
||||||
)}
|
|
||||||
>
|
|
||||||
{rank}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{rank === lastRank && (
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
'absolute bottom-1 right-1.5 text-[10px] font-bold leading-none',
|
|
||||||
isLight ? 'text-[#5f8d68]' : 'text-[#eef0e6]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{file}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isTarget && (
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
'absolute h-4 w-4 rounded-full',
|
|
||||||
piece ? 'h-full w-full rounded-none ring-4 ring-black/20 ring-inset' : 'bg-black/20',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{piece && (
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
'relative z-10 select-none',
|
|
||||||
canDrag && 'cursor-grab active:cursor-grabbing',
|
canDrag && 'cursor-grab active:cursor-grabbing',
|
||||||
piece.color === 'w'
|
!disabled && 'hover:brightness-110 focus-visible:z-20',
|
||||||
? 'text-[#fbfbfb] [text-shadow:0_1px_2px_rgb(0_0_0_/_0.55)]'
|
disabled && 'cursor-not-allowed',
|
||||||
: 'text-[#202124] [text-shadow:0_1px_1px_rgb(255_255_255_/_0.22)]',
|
|
||||||
)}
|
)}
|
||||||
|
aria-label={getSquareLabel(square, piece)}
|
||||||
|
aria-pressed={isSelected}
|
||||||
>
|
>
|
||||||
{PIECE_SYMBOLS[piece.color][piece.type]}
|
{isLastMoveSquare && (
|
||||||
|
<span className="absolute inset-1 rounded-md bg-amber-300/35 shadow-[0_0_0_1px_rgb(255_244_184_/_0.42)_inset,0_0_18px_rgb(245_158_11_/_0.28)]" />
|
||||||
|
)}
|
||||||
|
{isSelected && (
|
||||||
|
<span className="absolute inset-1 rounded-md border-2 border-sky-300 bg-sky-300/20 shadow-[0_0_18px_rgb(56_189_248_/_0.42)]" />
|
||||||
|
)}
|
||||||
|
{file === firstFile && (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'absolute left-1.5 top-1 text-[10px] font-bold leading-none',
|
||||||
|
isLight ? 'text-[#6f8f72]' : 'text-[#f0dfc1]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{rank}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{rank === lastRank && (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'absolute bottom-1 right-1.5 text-[10px] font-bold leading-none',
|
||||||
|
isLight ? 'text-[#6f8f72]' : 'text-[#f0dfc1]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{file}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isTarget && (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'absolute z-10 rounded-full transition',
|
||||||
|
piece
|
||||||
|
? 'inset-[12%] border-[3px] border-rose-500/55 shadow-[0_0_0_1px_rgb(255_255_255_/_0.26)_inset]'
|
||||||
|
: 'left-1/2 top-1/2 h-[24%] w-[24%] -translate-x-1/2 -translate-y-1/2 bg-slate-950/35 shadow-[0_1px_3px_rgb(0_0_0_/_0.24)]',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pointer-events-none absolute inset-0">
|
||||||
|
{visualPieces.map((piece) => {
|
||||||
|
const canDrag = !disabled && (isDraggableSquare?.(piece.square) ?? true);
|
||||||
|
const isSelected = selectedSquare === piece.square;
|
||||||
|
const isLastMovePiece = lastMoveSquares?.to === piece.square;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={piece.id}
|
||||||
|
className="absolute left-0 top-0 flex h-[12.5%] w-[12.5%] items-center justify-center transition-transform duration-[420ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
|
||||||
|
style={getSquarePosition(piece.square, orientation)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'flex h-[82%] w-[82%] select-none items-center justify-center rounded-full font-serif text-[2rem] font-bold leading-none transition-[transform,filter] duration-200 sm:text-[2.45rem] md:text-[3.35rem]',
|
||||||
|
canDrag && 'cursor-grab active:cursor-grabbing',
|
||||||
|
isSelected && 'scale-110 -translate-y-0.5 brightness-110',
|
||||||
|
isLastMovePiece && 'scale-105',
|
||||||
|
piece.color === 'w'
|
||||||
|
? 'border border-[#6a4f31]/45 bg-[radial-gradient(circle_at_35%_28%,#fffdf7_0%,#f5ead7_62%,#dbc8a5_100%)] text-[#2f271f] shadow-[0_5px_10px_rgb(53_39_25_/_0.30),0_1px_0_rgb(255_255_255_/_0.75)_inset] [text-shadow:0_1px_0_rgb(255_255_255_/_0.65)]'
|
||||||
|
: 'border border-black/45 bg-[radial-gradient(circle_at_35%_28%,#53565d_0%,#1f2329_64%,#090b0f_100%)] text-[#fff1d1] shadow-[0_7px_12px_rgb(0_0_0_/_0.42),0_1px_0_rgb(255_255_255_/_0.22)_inset] [text-shadow:0_1px_2px_rgb(0_0_0_/_0.8)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{PIECE_SYMBOLS[piece.color][piece.type]}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
);
|
||||||
</button>
|
})}
|
||||||
);
|
</div>
|
||||||
})}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,20 +9,23 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
Clipboard,
|
Clipboard,
|
||||||
|
Flag,
|
||||||
History,
|
History,
|
||||||
Home,
|
Home,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
PlusCircle,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Swords,
|
Swords,
|
||||||
|
Undo2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { getChessGame, postChessMove } from '@/api/chess';
|
import { getChessGame, postChessMove, resignChessGame, undoChessMove } from '@/api/chess';
|
||||||
import ChessBoard, { getMoveSquares, getTurnLabel, toChessJsColor } from '@/components/chess/ChessBoard';
|
import ChessBoard, { getMoveSquares, getTurnLabel, toChessJsColor, type MoveSquares } from '@/components/chess/ChessBoard';
|
||||||
import { getChessErrorMessage, isAuthError, outcomeBadgeTones, outcomeLabels } from '@/components/chess/chessUi';
|
import { getChessErrorMessage, getChessOutcomeLabel, isAuthError, outcomeBadgeTones } from '@/components/chess/chessUi';
|
||||||
import StatusBadge from '@/components/ui/StatusBadge';
|
import StatusBadge from '@/components/ui/StatusBadge';
|
||||||
import WindowSurface from '@/components/ui/WindowSurface';
|
import WindowSurface from '@/components/ui/WindowSurface';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import { ChessGameResponse } from '@/types';
|
import { ChessColor, ChessGameResponse } from '@/types';
|
||||||
|
|
||||||
interface ChessGamePlayClientProps {
|
interface ChessGamePlayClientProps {
|
||||||
gameId: string;
|
gameId: string;
|
||||||
@@ -46,6 +49,14 @@ const getPreferredMove = (moves: Move[], to: Square) => {
|
|||||||
|
|
||||||
const toUciMove = (move: Move) => `${move.from}${move.to}${move.promotion ?? ''}`;
|
const toUciMove = (move: Move) => `${move.from}${move.to}${move.promotion ?? ''}`;
|
||||||
|
|
||||||
|
const hasPlayerMoved = (moves: string[], playerColor: ChessColor) => {
|
||||||
|
return moves.some((_, index) => {
|
||||||
|
const side = index % 2 === 0 ? 'white' : 'black';
|
||||||
|
|
||||||
|
return side === playerColor;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
function ChessPageFrame({ children }: { children: ReactNode }) {
|
function ChessPageFrame({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex w-full min-w-0 max-w-[1180px] flex-col gap-5 px-0 py-3 md:py-6">
|
<main className="mx-auto flex w-full min-w-0 max-w-[1180px] flex-col gap-5 px-0 py-3 md:py-6">
|
||||||
@@ -109,19 +120,27 @@ function GameInfoPanel({
|
|||||||
game,
|
game,
|
||||||
isPlayerTurn,
|
isPlayerTurn,
|
||||||
pending,
|
pending,
|
||||||
|
canUndo,
|
||||||
|
canResign,
|
||||||
onCopyPgn,
|
onCopyPgn,
|
||||||
|
onUndo,
|
||||||
|
onResign,
|
||||||
}: {
|
}: {
|
||||||
game: ChessGameResponse;
|
game: ChessGameResponse;
|
||||||
isPlayerTurn: boolean;
|
isPlayerTurn: boolean;
|
||||||
pending: boolean;
|
pending: boolean;
|
||||||
|
canUndo: boolean;
|
||||||
|
canResign: boolean;
|
||||||
onCopyPgn: () => void;
|
onCopyPgn: () => void;
|
||||||
|
onUndo: () => void;
|
||||||
|
onResign: () => void;
|
||||||
}) {
|
}) {
|
||||||
const gameEnded = game.status !== 'IN_PROGRESS';
|
const gameEnded = game.status !== 'IN_PROGRESS';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WindowSurface title="Game Info" showTrafficLights={false} as="aside" bodyClassName="p-4 md:p-5">
|
<WindowSurface title="Game Info" showTrafficLights={false} as="aside" bodyClassName="p-4 md:p-5">
|
||||||
<div className="mb-4 flex min-w-0 flex-wrap items-center gap-2">
|
<div className="mb-4 flex min-w-0 flex-wrap items-center gap-2">
|
||||||
<StatusBadge tone={outcomeBadgeTones[game.outcome]}>{outcomeLabels[game.outcome]}</StatusBadge>
|
<StatusBadge tone={outcomeBadgeTones[game.outcome]}>{getChessOutcomeLabel(game.outcome, game.status)}</StatusBadge>
|
||||||
{game.result && <StatusBadge tone="neutral">{game.result}</StatusBadge>}
|
{game.result && <StatusBadge tone="neutral">{game.result}</StatusBadge>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -153,11 +172,43 @@ function GameInfoPanel({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{gameEnded && '종료된 대국입니다.'}
|
{gameEnded && '종료된 대국입니다.'}
|
||||||
{!gameEnded && pending && 'Maia 응답을 기다리는 중입니다.'}
|
{!gameEnded && pending && '요청을 처리하는 중입니다.'}
|
||||||
{!gameEnded && !pending && isPlayerTurn && '내 차례입니다. 말을 움직이세요.'}
|
{!gameEnded && !pending && isPlayerTurn && '내 차례입니다. 말을 움직이세요.'}
|
||||||
{!gameEnded && !pending && !isPlayerTurn && 'Maia 차례입니다.'}
|
{!gameEnded && !pending && !isPlayerTurn && 'Maia 차례입니다.'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-3 xl:grid-cols-1">
|
||||||
|
{game.status === 'IN_PROGRESS' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onUndo}
|
||||||
|
disabled={!canUndo}
|
||||||
|
className="inline-flex h-10 min-w-0 items-center justify-center gap-2 rounded-lg border border-[var(--control-border)] bg-[var(--color-control)] px-3 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Undo2 size={16} />
|
||||||
|
무르기
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onResign}
|
||||||
|
disabled={!canResign}
|
||||||
|
className="inline-flex h-10 min-w-0 items-center justify-center gap-2 rounded-lg border border-red-500/20 bg-red-500/10 px-3 text-sm font-semibold text-red-700 shadow-[var(--shadow-control)] transition hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-50 dark:text-red-300"
|
||||||
|
>
|
||||||
|
<Flag size={16} />
|
||||||
|
기권
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href="/chess"
|
||||||
|
className="inline-flex h-10 min-w-0 items-center justify-center gap-2 rounded-lg bg-[var(--color-accent)] px-3 text-sm font-semibold text-white shadow-[var(--shadow-control)] transition hover:bg-[var(--color-accent-hover)]"
|
||||||
|
>
|
||||||
|
<PlusCircle size={16} />
|
||||||
|
새 게임
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 rounded-lg border border-[var(--color-line)] bg-black/[0.025] px-3 py-3 dark:bg-white/[0.06]">
|
<div className="mt-4 rounded-lg border border-[var(--color-line)] bg-black/[0.025] px-3 py-3 dark:bg-white/[0.06]">
|
||||||
<p className="text-xs font-semibold text-[var(--color-text-subtle)]">마지막 Maia 수</p>
|
<p className="text-xs font-semibold text-[var(--color-text-subtle)]">마지막 Maia 수</p>
|
||||||
<p className="mt-1 break-words font-mono text-sm font-semibold text-[var(--color-text)]">
|
<p className="mt-1 break-words font-mono text-sm font-semibold text-[var(--color-text)]">
|
||||||
@@ -190,6 +241,7 @@ export default function ChessGamePlayClient({ gameId }: ChessGamePlayClientProps
|
|||||||
const { isLoggedIn, _hasHydrated } = useAuthStore();
|
const { isLoggedIn, _hasHydrated } = useAuthStore();
|
||||||
const [selectedSquare, setSelectedSquare] = useState<Square | null>(null);
|
const [selectedSquare, setSelectedSquare] = useState<Square | null>(null);
|
||||||
const [optimisticFen, setOptimisticFen] = useState<string | null>(null);
|
const [optimisticFen, setOptimisticFen] = useState<string | null>(null);
|
||||||
|
const [optimisticMoveSquares, setOptimisticMoveSquares] = useState<MoveSquares | null>(null);
|
||||||
const gameQueryKey = useMemo(() => ['chess-game', gameId] as const, [gameId]);
|
const gameQueryKey = useMemo(() => ['chess-game', gameId] as const, [gameId]);
|
||||||
|
|
||||||
const gameQuery = useQuery({
|
const gameQuery = useQuery({
|
||||||
@@ -199,26 +251,63 @@ export default function ChessGamePlayClient({ gameId }: ChessGamePlayClientProps
|
|||||||
retry: 0,
|
retry: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const syncUpdatedGame = async (updatedGame: ChessGameResponse) => {
|
||||||
|
queryClient.setQueryData(gameQueryKey, updatedGame);
|
||||||
|
setOptimisticFen(null);
|
||||||
|
setOptimisticMoveSquares(null);
|
||||||
|
setSelectedSquare(null);
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['chess-games'] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['chess-game-stats'] }),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
const moveMutation = useMutation({
|
const moveMutation = useMutation({
|
||||||
mutationFn: (move: string) => postChessMove(gameId, { move }),
|
mutationFn: (move: string) => postChessMove(gameId, { move }),
|
||||||
onSuccess: async (updatedGame) => {
|
onSuccess: async (updatedGame) => {
|
||||||
queryClient.setQueryData(gameQueryKey, updatedGame);
|
await syncUpdatedGame(updatedGame);
|
||||||
setOptimisticFen(null);
|
|
||||||
setSelectedSquare(null);
|
|
||||||
await Promise.all([
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['chess-games'] }),
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['chess-game-stats'] }),
|
|
||||||
]);
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
setOptimisticFen(null);
|
setOptimisticFen(null);
|
||||||
|
setOptimisticMoveSquares(null);
|
||||||
setSelectedSquare(null);
|
setSelectedSquare(null);
|
||||||
toast.error(getChessErrorMessage(error, '봇 응답을 가져오지 못했습니다. 잠시 후 다시 시도해주세요.'));
|
toast.error(getChessErrorMessage(error, '봇 응답을 가져오지 못했습니다. 잠시 후 다시 시도해주세요.'));
|
||||||
void gameQuery.refetch();
|
void gameQuery.refetch();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const undoMutation = useMutation({
|
||||||
|
mutationFn: () => undoChessMove(gameId),
|
||||||
|
onSuccess: async (updatedGame) => {
|
||||||
|
await syncUpdatedGame(updatedGame);
|
||||||
|
toast.success('무르기를 적용했습니다.');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setOptimisticFen(null);
|
||||||
|
setOptimisticMoveSquares(null);
|
||||||
|
setSelectedSquare(null);
|
||||||
|
toast.error(getChessErrorMessage(error, '무르기를 처리하지 못했습니다.'));
|
||||||
|
void gameQuery.refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const resignMutation = useMutation({
|
||||||
|
mutationFn: () => resignChessGame(gameId),
|
||||||
|
onSuccess: async (updatedGame) => {
|
||||||
|
await syncUpdatedGame(updatedGame);
|
||||||
|
toast.success('기권 처리되었습니다.');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setOptimisticFen(null);
|
||||||
|
setOptimisticMoveSquares(null);
|
||||||
|
setSelectedSquare(null);
|
||||||
|
toast.error(getChessErrorMessage(error, '기권을 처리하지 못했습니다.'));
|
||||||
|
void gameQuery.refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const game = gameQuery.data;
|
const game = gameQuery.data;
|
||||||
|
const isPending = moveMutation.isPending || undoMutation.isPending || resignMutation.isPending;
|
||||||
const currentFen = optimisticFen ?? game?.fen ?? INITIAL_FEN;
|
const currentFen = optimisticFen ?? game?.fen ?? INITIAL_FEN;
|
||||||
const localGame = useMemo(() => createGame(currentFen), [currentFen]);
|
const localGame = useMemo(() => createGame(currentFen), [currentFen]);
|
||||||
const playerColor = game ? toChessJsColor(game.playerColor) : null;
|
const playerColor = game ? toChessJsColor(game.playerColor) : null;
|
||||||
@@ -226,7 +315,7 @@ export default function ChessGamePlayClient({ gameId }: ChessGamePlayClientProps
|
|||||||
Boolean(game && localGame && playerColor) &&
|
Boolean(game && localGame && playerColor) &&
|
||||||
game?.status === 'IN_PROGRESS' &&
|
game?.status === 'IN_PROGRESS' &&
|
||||||
game.turn === game.playerColor &&
|
game.turn === game.playerColor &&
|
||||||
!moveMutation.isPending;
|
!isPending;
|
||||||
|
|
||||||
const legalMoves = useMemo<Move[]>(() => {
|
const legalMoves = useMemo<Move[]>(() => {
|
||||||
if (!localGame || !selectedSquare || !canInteract) return [];
|
if (!localGame || !selectedSquare || !canInteract) return [];
|
||||||
@@ -235,7 +324,8 @@ export default function ChessGamePlayClient({ gameId }: ChessGamePlayClientProps
|
|||||||
}, [canInteract, localGame, selectedSquare]);
|
}, [canInteract, localGame, selectedSquare]);
|
||||||
|
|
||||||
const legalTargets = useMemo(() => new Set(legalMoves.map((move) => move.to)), [legalMoves]);
|
const legalTargets = useMemo(() => new Set(legalMoves.map((move) => move.to)), [legalMoves]);
|
||||||
const lastMoveSquares = useMemo(() => getMoveSquares(game?.maiaMove ?? game?.moves.at(-1)), [game?.maiaMove, game?.moves]);
|
const serverLastMoveSquares = useMemo(() => getMoveSquares(game?.maiaMove ?? game?.moves.at(-1)), [game?.maiaMove, game?.moves]);
|
||||||
|
const lastMoveSquares = optimisticMoveSquares ?? serverLastMoveSquares;
|
||||||
|
|
||||||
const isOwnTurnPiece = (square: Square) => {
|
const isOwnTurnPiece = (square: Square) => {
|
||||||
if (!localGame || !playerColor || !canInteract) return false;
|
if (!localGame || !playerColor || !canInteract) return false;
|
||||||
@@ -266,9 +356,11 @@ export default function ChessGamePlayClient({ gameId }: ChessGamePlayClientProps
|
|||||||
});
|
});
|
||||||
|
|
||||||
setOptimisticFen(nextGame.fen());
|
setOptimisticFen(nextGame.fen());
|
||||||
|
setOptimisticMoveSquares({ from, to });
|
||||||
setSelectedSquare(null);
|
setSelectedSquare(null);
|
||||||
moveMutation.mutate(toUciMove(candidate));
|
moveMutation.mutate(toUciMove(candidate));
|
||||||
} catch {
|
} catch {
|
||||||
|
setOptimisticMoveSquares(null);
|
||||||
toast.error('합법적인 수가 아닙니다.');
|
toast.error('합법적인 수가 아닙니다.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -313,6 +405,29 @@ export default function ChessGamePlayClient({ gameId }: ChessGamePlayClientProps
|
|||||||
.catch(() => toast.error('PGN 복사에 실패했습니다.'));
|
.catch(() => toast.error('PGN 복사에 실패했습니다.'));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canResign = Boolean(game && game.status === 'IN_PROGRESS' && !isPending);
|
||||||
|
const canUndo = Boolean(game && game.status === 'IN_PROGRESS' && !isPending && hasPlayerMoved(game.moves, game.playerColor));
|
||||||
|
const pendingLabel = resignMutation.isPending
|
||||||
|
? '기권 처리 중'
|
||||||
|
: undoMutation.isPending
|
||||||
|
? '무르기 처리 중'
|
||||||
|
: moveMutation.isPending
|
||||||
|
? 'Maia 생각 중'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const handleUndo = () => {
|
||||||
|
if (!canUndo) return;
|
||||||
|
|
||||||
|
undoMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResign = () => {
|
||||||
|
if (!canResign) return;
|
||||||
|
if (!window.confirm('정말 기권하시겠습니까? 이 대국은 패배로 종료됩니다.')) return;
|
||||||
|
|
||||||
|
resignMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
if (!_hasHydrated) return <LoadingState message="로그인 상태를 확인하는 중입니다." />;
|
if (!_hasHydrated) return <LoadingState message="로그인 상태를 확인하는 중입니다." />;
|
||||||
if (!isLoggedIn) return <LoginRequired />;
|
if (!isLoggedIn) return <LoginRequired />;
|
||||||
if (gameQuery.isLoading) return <LoadingState />;
|
if (gameQuery.isLoading) return <LoadingState />;
|
||||||
@@ -377,11 +492,11 @@ export default function ChessGamePlayClient({ gameId }: ChessGamePlayClientProps
|
|||||||
<section className="grid min-w-0 items-start gap-5 xl:grid-cols-[minmax(0,1fr)_22rem]">
|
<section className="grid min-w-0 items-start gap-5 xl:grid-cols-[minmax(0,1fr)_22rem]">
|
||||||
<WindowSurface
|
<WindowSurface
|
||||||
title="Board"
|
title="Board"
|
||||||
subtitle={moveMutation.isPending ? 'Maia thinking' : isPlayerTurn ? 'Your move' : 'Synced'}
|
subtitle={isPending ? 'Updating' : isPlayerTurn ? 'Your move' : 'Synced'}
|
||||||
showTrafficLights={false}
|
showTrafficLights={false}
|
||||||
bodyClassName="p-3 md:p-5"
|
bodyClassName="p-3 md:p-5"
|
||||||
>
|
>
|
||||||
<div className="relative mx-auto max-w-full" style={{ width: 'min(100%, clamp(18rem, calc(100svh - 15rem), 42rem))' }}>
|
<div className="relative mx-auto max-w-full" style={{ width: 'min(100%, clamp(18rem, calc(100svh - 23rem), 40rem))' }}>
|
||||||
<ChessBoard
|
<ChessBoard
|
||||||
fen={currentFen}
|
fen={currentFen}
|
||||||
orientation={game.playerColor}
|
orientation={game.playerColor}
|
||||||
@@ -393,11 +508,11 @@ export default function ChessGamePlayClient({ gameId }: ChessGamePlayClientProps
|
|||||||
onSquareClick={handleSquareClick}
|
onSquareClick={handleSquareClick}
|
||||||
onSquareDrop={attemptMove}
|
onSquareDrop={attemptMove}
|
||||||
/>
|
/>
|
||||||
{moveMutation.isPending && (
|
{isPending && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-black/20 backdrop-blur-[2px]">
|
<div className="pointer-events-none absolute bottom-3 left-1/2 z-30 -translate-x-1/2">
|
||||||
<div className="flex items-center gap-2 rounded-full border border-white/20 bg-black/55 px-4 py-2 text-sm font-semibold text-white shadow-[var(--shadow-control)]">
|
<div className="flex items-center gap-2 rounded-full border border-white/25 bg-black/62 px-4 py-2 text-sm font-semibold text-white shadow-[0_12px_28px_rgb(0_0_0_/_0.28)] backdrop-blur-md">
|
||||||
<Loader2 className="animate-spin" size={17} />
|
<Loader2 className="animate-spin" size={16} />
|
||||||
Maia 응답 대기
|
{pendingLabel || '처리 중'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -407,8 +522,12 @@ export default function ChessGamePlayClient({ gameId }: ChessGamePlayClientProps
|
|||||||
<GameInfoPanel
|
<GameInfoPanel
|
||||||
game={game}
|
game={game}
|
||||||
isPlayerTurn={isPlayerTurn}
|
isPlayerTurn={isPlayerTurn}
|
||||||
pending={moveMutation.isPending}
|
pending={isPending}
|
||||||
|
canUndo={canUndo}
|
||||||
|
canResign={canResign}
|
||||||
onCopyPgn={copyPgn}
|
onCopyPgn={copyPgn}
|
||||||
|
onUndo={handleUndo}
|
||||||
|
onResign={handleResign}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</ChessPageFrame>
|
</ChessPageFrame>
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import { getTurnLabel } from '@/components/chess/ChessBoard';
|
|||||||
import {
|
import {
|
||||||
formatChessDateTime,
|
formatChessDateTime,
|
||||||
getChessErrorMessage,
|
getChessErrorMessage,
|
||||||
|
getChessOutcomeLabel,
|
||||||
isAuthError,
|
isAuthError,
|
||||||
outcomeBadgeTones,
|
outcomeBadgeTones,
|
||||||
outcomeLabels,
|
|
||||||
type OutcomeFilter,
|
type OutcomeFilter,
|
||||||
} from '@/components/chess/chessUi';
|
} from '@/components/chess/chessUi';
|
||||||
import SegmentedControl, { SegmentedControlOption } from '@/components/ui/SegmentedControl';
|
import SegmentedControl, { SegmentedControlOption } from '@/components/ui/SegmentedControl';
|
||||||
@@ -95,7 +95,7 @@ function HistoryRow({ game }: { game: ChessGameSummaryResponse }) {
|
|||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="mb-1.5 flex min-w-0 flex-wrap items-center gap-2">
|
<div className="mb-1.5 flex min-w-0 flex-wrap items-center gap-2">
|
||||||
<StatusBadge tone={outcomeBadgeTones[game.outcome]}>{outcomeLabels[game.outcome]}</StatusBadge>
|
<StatusBadge tone={outcomeBadgeTones[game.outcome]}>{getChessOutcomeLabel(game.outcome, game.status)}</StatusBadge>
|
||||||
{game.result && <StatusBadge tone="neutral">{game.result}</StatusBadge>}
|
{game.result && <StatusBadge tone="neutral">{game.result}</StatusBadge>}
|
||||||
</div>
|
</div>
|
||||||
<p className="truncate text-sm font-bold text-[var(--color-text)]">
|
<p className="truncate text-sm font-bold text-[var(--color-text)]">
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { createChessGame, getChessGames, getChessGameStats } from '@/api/chess';
|
import { createChessGame, getChessGames, getChessGameStats } from '@/api/chess';
|
||||||
import { getTurnLabel } from '@/components/chess/ChessBoard';
|
import { getTurnLabel } from '@/components/chess/ChessBoard';
|
||||||
import { formatChessDateTime, getChessErrorMessage, outcomeBadgeTones, outcomeLabels } from '@/components/chess/chessUi';
|
import { formatChessDateTime, getChessErrorMessage, getChessOutcomeLabel, outcomeBadgeTones, outcomeLabels } from '@/components/chess/chessUi';
|
||||||
import MetricCard from '@/components/ui/MetricCard';
|
import MetricCard from '@/components/ui/MetricCard';
|
||||||
import SegmentedControl, { SegmentedControlOption } from '@/components/ui/SegmentedControl';
|
import SegmentedControl, { SegmentedControlOption } from '@/components/ui/SegmentedControl';
|
||||||
import StatusBadge from '@/components/ui/StatusBadge';
|
import StatusBadge from '@/components/ui/StatusBadge';
|
||||||
@@ -112,6 +112,7 @@ function GameSummaryRow({
|
|||||||
playerColor: ChessColor;
|
playerColor: ChessColor;
|
||||||
model: string;
|
model: string;
|
||||||
outcome: keyof typeof outcomeLabels;
|
outcome: keyof typeof outcomeLabels;
|
||||||
|
status: string;
|
||||||
movesCount: number;
|
movesCount: number;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
@@ -123,7 +124,7 @@ function GameSummaryRow({
|
|||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="mb-1.5 flex min-w-0 flex-wrap items-center gap-2">
|
<div className="mb-1.5 flex min-w-0 flex-wrap items-center gap-2">
|
||||||
<StatusBadge tone={outcomeBadgeTones[game.outcome]}>{outcomeLabels[game.outcome]}</StatusBadge>
|
<StatusBadge tone={outcomeBadgeTones[game.outcome]}>{getChessOutcomeLabel(game.outcome, game.status)}</StatusBadge>
|
||||||
<span className="text-xs font-semibold text-[var(--color-text-subtle)]">
|
<span className="text-xs font-semibold text-[var(--color-text-subtle)]">
|
||||||
Maia {game.model} · {game.rating}
|
Maia {game.model} · {game.rating}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import type { CSSProperties, ReactNode } from 'react';
|
import type { CSSProperties, ReactNode } from 'react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Chess as ChessGame, type Color, type Move, type PieceSymbol, type Square } from 'chess.js';
|
import { Chess as ChessGame, type Color, type Move, type Square } from 'chess.js';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -15,45 +15,15 @@ import {
|
|||||||
Target,
|
Target,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { getTodayChessPuzzle } from '@/api/chess';
|
import { getTodayChessPuzzle } from '@/api/chess';
|
||||||
|
import ChessBoard, { BOARD_SQUARES, type MoveSquares } from '@/components/chess/ChessBoard';
|
||||||
import WindowSurface from '@/components/ui/WindowSurface';
|
import WindowSurface from '@/components/ui/WindowSurface';
|
||||||
import { type ChessPuzzle } from '@/types';
|
import { type ChessPuzzle } from '@/types';
|
||||||
|
|
||||||
type FeedbackTone = 'neutral' | 'success' | 'error';
|
type FeedbackTone = 'neutral' | 'success' | 'error';
|
||||||
|
|
||||||
const TIMEZONE = 'Asia/Seoul';
|
const TIMEZONE = 'Asia/Seoul';
|
||||||
const FILES = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] as const;
|
|
||||||
const RANKS = [8, 7, 6, 5, 4, 3, 2, 1] as const;
|
|
||||||
const BOARD_SQUARES = RANKS.flatMap((rank) => FILES.map((file) => `${file}${rank}` as Square));
|
|
||||||
const BOARD_SIZE_STYLE: CSSProperties = {
|
const BOARD_SIZE_STYLE: CSSProperties = {
|
||||||
width: 'min(100%, clamp(18rem, calc(100svh - 15rem), 42rem))',
|
width: 'min(100%, clamp(18rem, calc(100svh - 23rem), 40rem))',
|
||||||
};
|
|
||||||
|
|
||||||
const PIECE_SYMBOLS: Record<Color, Record<PieceSymbol, string>> = {
|
|
||||||
w: {
|
|
||||||
k: '♔',
|
|
||||||
q: '♕',
|
|
||||||
r: '♖',
|
|
||||||
b: '♗',
|
|
||||||
n: '♘',
|
|
||||||
p: '♙',
|
|
||||||
},
|
|
||||||
b: {
|
|
||||||
k: '♚',
|
|
||||||
q: '♛',
|
|
||||||
r: '♜',
|
|
||||||
b: '♝',
|
|
||||||
n: '♞',
|
|
||||||
p: '♟',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const PIECE_NAMES: Record<PieceSymbol, string> = {
|
|
||||||
k: '킹',
|
|
||||||
q: '퀸',
|
|
||||||
r: '룩',
|
|
||||||
b: '비숍',
|
|
||||||
n: '나이트',
|
|
||||||
p: '폰',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const turnLabel = (color: Color) => (color === 'w' ? '백' : '흑');
|
const turnLabel = (color: Color) => (color === 'w' ? '백' : '흑');
|
||||||
@@ -67,10 +37,10 @@ const getReadyFeedback = (puzzle: ChessPuzzle): { tone: FeedbackTone; message: s
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSquareLabel = (square: Square, piece?: { color: Color; type: PieceSymbol }) => {
|
const getPreferredMove = (moves: Move[], to: Square) => {
|
||||||
if (!piece) return `${square} 빈 칸`;
|
const candidates = moves.filter((move) => move.to === to);
|
||||||
|
|
||||||
return `${square} ${turnLabel(piece.color)} ${PIECE_NAMES[piece.type]}`;
|
return candidates.find((move) => move.promotion === 'q') ?? candidates[0] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (value: string) => {
|
const formatDate = (value: string) => {
|
||||||
@@ -176,7 +146,7 @@ function ChessPuzzleBoard({ puzzle }: { puzzle: ChessPuzzle }) {
|
|||||||
const [currentFen, setCurrentFen] = useState(puzzle.fen);
|
const [currentFen, setCurrentFen] = useState(puzzle.fen);
|
||||||
const [selectedSquare, setSelectedSquare] = useState<Square | null>(null);
|
const [selectedSquare, setSelectedSquare] = useState<Square | null>(null);
|
||||||
const [solved, setSolved] = useState(false);
|
const [solved, setSolved] = useState(false);
|
||||||
const [lastMoveSquares, setLastMoveSquares] = useState<{ from: Square; to: Square } | null>(null);
|
const [lastMoveSquares, setLastMoveSquares] = useState<MoveSquares | null>(null);
|
||||||
const [feedback, setFeedback] = useState(getReadyFeedback(puzzle));
|
const [feedback, setFeedback] = useState(getReadyFeedback(puzzle));
|
||||||
|
|
||||||
const game = useMemo(() => new ChessGame(currentFen), [currentFen]);
|
const game = useMemo(() => new ChessGame(currentFen), [currentFen]);
|
||||||
@@ -206,8 +176,19 @@ function ChessPuzzleBoard({ puzzle }: { puzzle: ChessPuzzle }) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isDraggablePuzzleSquare = (square: Square) => {
|
||||||
|
if (solved) return false;
|
||||||
|
|
||||||
|
const piece = game.get(square);
|
||||||
|
|
||||||
|
return piece?.color === game.turn();
|
||||||
|
};
|
||||||
|
|
||||||
const tryMove = (from: Square, to: Square) => {
|
const tryMove = (from: Square, to: Square) => {
|
||||||
const candidate = legalMoves.find((move) => move.to === to);
|
if (solved || !isDraggablePuzzleSquare(from)) return;
|
||||||
|
|
||||||
|
const movesFromSquare = game.moves({ square: from, verbose: true });
|
||||||
|
const candidate = getPreferredMove(movesFromSquare, to);
|
||||||
|
|
||||||
if (!candidate) {
|
if (!candidate) {
|
||||||
setFeedback({
|
setFeedback({
|
||||||
@@ -290,77 +271,16 @@ function ChessPuzzleBoard({ puzzle }: { puzzle: ChessPuzzle }) {
|
|||||||
|
|
||||||
<section className="grid min-w-0 items-start gap-5 lg:grid-cols-[minmax(0,1fr)_20rem]">
|
<section className="grid min-w-0 items-start gap-5 lg:grid-cols-[minmax(0,1fr)_20rem]">
|
||||||
<BoardWindow>
|
<BoardWindow>
|
||||||
<div className="grid aspect-square w-full grid-cols-8 overflow-hidden rounded-lg border border-[var(--color-line)] shadow-[var(--shadow-control)]">
|
<ChessBoard
|
||||||
{BOARD_SQUARES.map((square, index) => {
|
fen={currentFen}
|
||||||
const piece = game.get(square);
|
selectedSquare={selectedSquare}
|
||||||
const file = square[0];
|
legalTargets={legalTargets}
|
||||||
const rank = square[1];
|
lastMoveSquares={lastMoveSquares}
|
||||||
const fileIndex = index % 8;
|
disabled={solved}
|
||||||
const rankIndex = Math.floor(index / 8);
|
isDraggableSquare={isDraggablePuzzleSquare}
|
||||||
const isLight = (fileIndex + rankIndex) % 2 === 0;
|
onSquareClick={handleSquareClick}
|
||||||
const isSelected = selectedSquare === square;
|
onSquareDrop={tryMove}
|
||||||
const isTarget = legalTargets.has(square);
|
/>
|
||||||
const isLastMoveSquare = lastMoveSquares?.from === square || lastMoveSquares?.to === square;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={square}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleSquareClick(square)}
|
|
||||||
className={clsx(
|
|
||||||
'relative flex aspect-square items-center justify-center overflow-hidden text-[2rem] leading-none transition sm:text-[2.5rem] md:text-[3.75rem]',
|
|
||||||
isLight ? 'bg-[#eef0e6]' : 'bg-[#5f8d68]',
|
|
||||||
isSelected && 'z-10 ring-4 ring-[var(--color-accent)] ring-inset',
|
|
||||||
isLastMoveSquare && 'bg-[var(--color-accent-soft)]',
|
|
||||||
!solved && 'hover:brightness-105 focus-visible:z-20',
|
|
||||||
)}
|
|
||||||
aria-label={getSquareLabel(square, piece)}
|
|
||||||
aria-pressed={isSelected}
|
|
||||||
>
|
|
||||||
{file === 'a' && (
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
'absolute left-1.5 top-1 text-[10px] font-bold leading-none',
|
|
||||||
isLight ? 'text-[#5f8d68]' : 'text-[#eef0e6]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{rank}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{rank === '1' && (
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
'absolute bottom-1 right-1.5 text-[10px] font-bold leading-none',
|
|
||||||
isLight ? 'text-[#5f8d68]' : 'text-[#eef0e6]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{file}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isTarget && (
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
'absolute h-4 w-4 rounded-full',
|
|
||||||
piece ? 'h-full w-full rounded-none ring-4 ring-black/20 ring-inset' : 'bg-black/20',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{piece && (
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
'relative z-10 select-none',
|
|
||||||
piece.color === 'w'
|
|
||||||
? 'text-[#fbfbfb] [text-shadow:0_1px_2px_rgb(0_0_0_/_0.55)]'
|
|
||||||
: 'text-[#202124] [text-shadow:0_1px_1px_rgb(255_255_255_/_0.22)]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{PIECE_SYMBOLS[piece.color][piece.type]}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</BoardWindow>
|
</BoardWindow>
|
||||||
|
|
||||||
<WindowSurface title="Puzzle" showTrafficLights={false} as="aside" bodyClassName="p-4 md:p-5">
|
<WindowSurface title="Puzzle" showTrafficLights={false} as="aside" bodyClassName="p-4 md:p-5">
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ export const outcomeBadgeTones: Record<ChessOutcome, 'neutral' | 'info' | 'succe
|
|||||||
UNKNOWN: 'neutral',
|
UNKNOWN: 'neutral',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getChessOutcomeLabel = (outcome: ChessOutcome, status?: string) => {
|
||||||
|
if (status === 'RESIGNED' && outcome === 'LOSS') return '기권패';
|
||||||
|
|
||||||
|
return outcomeLabels[outcome];
|
||||||
|
};
|
||||||
|
|
||||||
export const formatChessDateTime = (value?: string) => {
|
export const formatChessDateTime = (value?: string) => {
|
||||||
if (!value) return '';
|
if (!value) return '';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user