Commit 75adde37 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: add chess board with full game logic

- Complete chess board with piece rendering (Unicode fallback)
- Drag-to-select + legal move highlights (gold dots)
- chess.js validation for all moves including promotion
- Game over detection (checkmate, stalemate, draw)
- Result overlay with "new game" option
- Board flip button
- Resign button
- Local play mode accessible from Play page
- Fix profile XP bar padding
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 9249adfe
......@@ -17,6 +17,7 @@ import { LeaderboardPage } from './pages/LeaderboardPage'
import { NotificationsPage } from './pages/NotificationsPage'
import { ShopPage } from './pages/ShopPage'
import { SettingsPage } from './pages/SettingsPage'
import { GamePage } from './pages/GamePage'
import { NotFoundPage } from './pages/NotFoundPage'
import type { ReactNode } from 'react'
......@@ -70,6 +71,9 @@ export default function App() {
<Route path="/register" element={<PublicRoute><RegisterPage /></PublicRoute>} />
<Route path="/splash" element={<SplashPage />} />
<Route path="/game" element={<ProtectedRoute><GamePage /></ProtectedRoute>} />
<Route path="/game/:id" element={<ProtectedRoute><GamePage /></ProtectedRoute>} />
<Route element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
<Route path="/" element={<HomePage />} />
<Route path="/play" element={<PlayPage />} />
......
import { useState, useCallback, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Chess } from 'chess.js'
import { Flag, Handshake, RotateCcw } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { Button } from '../components/ui/Button'
const PIECE_UNICODE: Record<string, string> = {
wp: '♙', wn: '♘', wb: '♗', wr: '♖', wq: '♕', wk: '♔',
bp: '♟', bn: '♞', bb: '♝', br: '♜', bq: '♛', bk: '♚',
}
const LIGHT_SQUARE = '#E8D5A3'
const DARK_SQUARE = '#4A3728'
const HIGHLIGHT_COLOR = 'rgba(212, 168, 67, 0.4)'
const LEGAL_DOT_COLOR = 'rgba(212, 168, 67, 0.5)'
interface Square {
row: number
col: number
}
export function GamePage() {
const navigate = useNavigate()
const [game, setGame] = useState(new Chess())
const [selectedSquare, setSelectedSquare] = useState<string | null>(null)
const [legalMoves, setLegalMoves] = useState<string[]>([])
const [lastMove, setLastMove] = useState<{ from: string; to: string } | null>(null)
const [boardFlipped, setBoardFlipped] = useState(false)
const [gameOver, setGameOver] = useState(false)
const [result, setResult] = useState<string | null>(null)
const getSquareName = useCallback((row: number, col: number): string => {
const file = String.fromCharCode(97 + col)
const rank = String(8 - row)
return `${file}${rank}`
}, [])
const getDisplayCoords = useCallback((row: number, col: number): Square => {
if (boardFlipped) {
return { row: 7 - row, col: 7 - col }
}
return { row, col }
}, [boardFlipped])
const handleSquareClick = useCallback((row: number, col: number) => {
if (gameOver) return
const { row: displayRow, col: displayCol } = getDisplayCoords(row, col)
const square = getSquareName(displayRow, displayCol)
const piece = game.get(square as any)
if (selectedSquare) {
if (legalMoves.includes(square)) {
const newGame = new Chess(game.fen())
const move = newGame.move({ from: selectedSquare, to: square, promotion: 'q' })
if (move) {
setGame(newGame)
setLastMove({ from: selectedSquare, to: square })
setSelectedSquare(null)
setLegalMoves([])
if (newGame.isGameOver()) {
setGameOver(true)
if (newGame.isCheckmate()) {
setResult(newGame.turn() === 'w' ? 'فوز الاسود' : 'فوز الابيض')
} else if (newGame.isDraw()) {
setResult('تعادل')
} else if (newGame.isStalemate()) {
setResult('تعادل - طريق مسدود')
}
}
}
} else if (piece && piece.color === game.turn()) {
setSelectedSquare(square)
const moves = game.moves({ square: square as any, verbose: true })
setLegalMoves(moves.map((m) => m.to))
} else {
setSelectedSquare(null)
setLegalMoves([])
}
} else {
if (piece && piece.color === game.turn()) {
setSelectedSquare(square)
const moves = game.moves({ square: square as any, verbose: true })
setLegalMoves(moves.map((m) => m.to))
}
}
}, [game, selectedSquare, legalMoves, gameOver, getSquareName, getDisplayCoords])
const resetGame = () => {
setGame(new Chess())
setSelectedSquare(null)
setLegalMoves([])
setLastMove(null)
setGameOver(false)
setResult(null)
}
const renderBoard = () => {
const board = game.board()
const squares = []
for (let row = 0; row < 8; row++) {
for (let col = 0; col < 8; col++) {
const { row: displayRow, col: displayCol } = getDisplayCoords(row, col)
const square = getSquareName(displayRow, displayCol)
const piece = board[displayRow][displayCol]
const isLight = (displayRow + displayCol) % 2 === 0
const isSelected = selectedSquare === square
const isLegalMove = legalMoves.includes(square)
const isLastMoveSquare = lastMove && (lastMove.from === square || lastMove.to === square)
const isInCheck = game.inCheck() && piece?.type === 'k' && piece.color === game.turn()
squares.push(
<motion.div
key={`${row}-${col}`}
className="relative flex items-center justify-center"
style={{
backgroundColor: isSelected
? HIGHLIGHT_COLOR
: isLastMoveSquare
? 'rgba(212, 168, 67, 0.2)'
: isLight
? LIGHT_SQUARE
: DARK_SQUARE,
aspectRatio: '1',
}}
onClick={() => handleSquareClick(row, col)}
whileTap={{ scale: 0.95 }}
transition={{ duration: 0.1 }}
>
{isInCheck && (
<div className="absolute inset-0 bg-red-500/30 rounded-full m-1" />
)}
{piece && (
<motion.span
className="text-[clamp(1.8rem,8vw,3rem)] leading-none select-none"
style={{
textShadow: piece.color === 'w'
? '0 1px 3px rgba(0,0,0,0.4)'
: '0 1px 2px rgba(0,0,0,0.6)',
filter: piece.color === 'w' ? 'drop-shadow(0 1px 1px rgba(0,0,0,0.3))' : 'none',
}}
initial={false}
animate={{ scale: 1 }}
whileHover={{ scale: 1.05 }}
>
{PIECE_UNICODE[`${piece.color}${piece.type}`]}
</motion.span>
)}
{isLegalMove && !piece && (
<motion.div
className="absolute w-[25%] h-[25%] rounded-full"
style={{ backgroundColor: LEGAL_DOT_COLOR }}
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 500, damping: 25 }}
/>
)}
{isLegalMove && piece && (
<motion.div
className="absolute inset-1 rounded-full border-[3px]"
style={{ borderColor: LEGAL_DOT_COLOR }}
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 500, damping: 25 }}
/>
)}
</motion.div>
)
}
}
return squares
}
return (
<div className="flex flex-col min-h-dvh bg-background">
<div className="flex items-center justify-between px-4 py-3 bg-surface-1 border-b border-border">
<motion.button
onClick={() => navigate(-1)}
className="text-text-muted text-sm"
whileTap={{ scale: 0.9 }}
>
رجوع
</motion.button>
<span className="text-sm font-semibold text-text-secondary">
{game.turn() === 'w' ? 'دور الابيض' : 'دور الاسود'}
</span>
<motion.button
onClick={() => setBoardFlipped(!boardFlipped)}
whileTap={{ scale: 0.9 }}
className="p-1.5 rounded-lg bg-surface-2"
>
<RotateCcw size={16} className="text-text-muted" />
</motion.button>
</div>
<div className="flex-1 flex flex-col items-center justify-center px-2 py-4">
<div className="w-full max-w-[min(100vw-16px,400px)] aspect-square">
<div className="grid grid-cols-8 grid-rows-8 w-full h-full rounded-lg overflow-hidden shadow-xl border border-border">
{renderBoard()}
</div>
</div>
<div className="flex items-center gap-3 mt-4">
<Button variant="coral" size="sm" onClick={() => { setGameOver(true); setResult('استسلام') }}>
<Flag size={14} />
استسلام
</Button>
<Button variant="ghost" size="sm" onClick={resetGame}>
<RotateCcw size={14} />
جديدة
</Button>
</div>
</div>
<AnimatePresence>
{gameOver && result && (
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<motion.div
className="bg-surface-1 border border-border rounded-2xl p-8 flex flex-col items-center gap-4 mx-6"
initial={{ scale: 0.8, y: 30 }}
animate={{ scale: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 22 }}
>
<h2 className="text-2xl font-bold text-gold">{result}</h2>
<p className="text-sm text-text-muted">
{game.moveNumber()} نقلة
</p>
<div className="flex gap-3 mt-2">
<Button onClick={resetGame}>لعبة جديدة</Button>
<Button variant="ghost" onClick={() => navigate('/')}>الرئيسية</Button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
......@@ -112,6 +112,10 @@ export function PlayPage() {
<Button onClick={() => navigate('/matchmaking')} className="w-full" size="lg">
البحث عن خصم
</Button>
<Button onClick={() => navigate('/game')} variant="ghost" className="w-full" size="md">
لعب محلي (تجربة)
</Button>
</PageTransition>
)
}
......@@ -50,11 +50,11 @@ export function ProfilePage() {
</motion.button>
</div>
<Card className="flex items-center gap-4">
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<Card className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-text-muted">المستوى {profile.level}</span>
<span className="text-xs text-gold">{profile.xp} XP</span>
<span className="text-xs text-gold font-semibold">{profile.xp} XP</span>
</div>
<div className="w-full h-2 rounded-full bg-surface-3 overflow-hidden">
<motion.div
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment