Commit 4f3da099 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: Pro Chess Player Overhaul - complete rewrite with 23 pro features

- Pro board: drag/drop, pre-moves, arrows, highlights, keyboard nav, coords
- Pro timer: Fischer/Bronstein/delay, tenths/hundredths, low-time warnings
- Pro move stack: clickable, scrollable, opening detection (100+ openings)
- Post-game analysis: eval bar, eval graph, move classification, accuracy %
- Puzzle trainer: daily puzzles, streak mode, rush mode, themed puzzles
- Sound system: move/capture/check/castle/game-end via Web Audio API
- Eval bar: real-time position evaluation display
- PGN export, FEN copy, analyze button
- 50 seeded puzzles in database with ELO rating system
- New icons: eye, download, copy, chart, puzzle, brain
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 4f492cea
<?php
require_once __DIR__ . '/../config/database.php';
header('Content-Type: application/json');
$token = get_auth_token();
if (!$token) {
http_response_code(401);
echo json_encode(['error' => 'unauthorized']);
exit;
}
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
$gameId = $_GET['id'] ?? '';
if (!$gameId) {
http_response_code(400);
echo json_encode(['error' => 'missing game id']);
exit;
}
$res = supabase_rest('GET', "games?id=eq.{$gameId}&select=id,bot_id,player_color,pgn,fen,final_fen,result,reason,time_control,increment,rated,status,started_at,ended_at,game_state,clock_white,clock_black", [], $token);
if ($res['status'] >= 200 && $res['status'] < 300 && !empty($res['data'])) {
$game = $res['data'][0];
// If analysis already cached in game_state, return it
$gameState = $game['game_state'] ?? null;
if (is_string($gameState)) {
$gameState = json_decode($gameState, true);
}
$hasAnalysis = isset($gameState['analysis']) && !empty($gameState['analysis']);
echo json_encode([
'game' => $game,
'has_analysis' => $hasAnalysis,
'analysis' => $hasAnalysis ? $gameState['analysis'] : null
]);
} else {
http_response_code(404);
echo json_encode(['error' => 'game not found']);
}
exit;
}
// POST requests
$input = json_decode(file_get_contents('php://input'), true);
$action = $input['action'] ?? '';
switch ($action) {
case 'analyze':
$gameId = $input['game_id'] ?? '';
$positions = $input['positions'] ?? []; // Array of FEN strings
if (!$gameId || empty($positions)) {
http_response_code(400);
echo json_encode(['error' => 'missing game_id or positions']);
exit;
}
$evaluations = [];
$apiUrl = STOCKFISH_API . '/api/chess/move';
foreach ($positions as $index => $fen) {
$payload = json_encode([
'bot_id' => 'grandmaster',
'fen' => $fen,
'wtime' => 120000,
'btime' => 120000
]);
$ch = curl_init($apiUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'X-API-Key: ' . STOCKFISH_MGMT_KEY
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
$data = json_decode($response, true);
$evaluations[] = [
'move_index' => $index,
'fen' => $fen,
'best_move' => $data['best_move'] ?? null,
'evaluation' => $data['evaluation'] ?? 0,
'depth' => $data['depth'] ?? 20
];
} else {
$evaluations[] = [
'move_index' => $index,
'fen' => $fen,
'best_move' => null,
'evaluation' => 0,
'depth' => 0,
'error' => true
];
}
// Small delay to avoid overwhelming the API
if ($index < count($positions) - 1) {
usleep(100000); // 100ms
}
}
// Cache analysis in game_state
$analysisData = [
'evaluations' => $evaluations,
'analyzed_at' => date('c')
];
// Get existing game_state
$gameRes = supabase_rest('GET', "games?id=eq.{$gameId}&select=game_state", [], $token);
$existingState = [];
if (!empty($gameRes['data'][0]['game_state'])) {
$existingState = $gameRes['data'][0]['game_state'];
if (is_string($existingState)) {
$existingState = json_decode($existingState, true) ?? [];
}
}
$existingState['analysis'] = $analysisData;
supabase_rest('PATCH', "games?id=eq.{$gameId}", [
'game_state' => json_encode($existingState)
], $token);
echo json_encode([
'ok' => true,
'analysis' => $analysisData
]);
break;
case 'analyze_single':
// Analyze a single position (used for real-time partial analysis)
$fen = $input['fen'] ?? '';
if (!$fen) {
http_response_code(400);
echo json_encode(['error' => 'missing fen']);
exit;
}
$apiUrl = STOCKFISH_API . '/api/chess/move';
$payload = json_encode([
'bot_id' => 'grandmaster',
'fen' => $fen,
'wtime' => 120000,
'btime' => 120000
]);
$ch = curl_init($apiUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'X-API-Key: ' . STOCKFISH_MGMT_KEY
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
$data = json_decode($response, true);
echo json_encode([
'best_move' => $data['best_move'] ?? null,
'evaluation' => $data['evaluation'] ?? 0,
'depth' => $data['depth'] ?? 20
]);
} else {
http_response_code(502);
echo json_encode(['error' => 'engine unavailable']);
}
break;
default:
http_response_code(400);
echo json_encode(['error' => 'invalid action']);
}
<?php
require_once __DIR__ . '/../config/database.php';
header('Content-Type: application/json');
$token = get_auth_token();
if (!$token) {
http_response_code(401);
echo json_encode(['error' => 'unauthorized']);
exit;
}
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
$action = $_GET['action'] ?? '';
if ($action === 'daily') {
// Get today's 3 daily puzzles (seeded by date)
$today = date('Y-m-d');
$seed = crc32($today);
// Fetch puzzles and use the seed to deterministically select 3
$offset = abs($seed) % 100;
$res = supabase_rest('GET', "puzzles?select=id,fen,moves,rating,themes,title&order=id.asc&offset={$offset}&limit=3", [], $token);
if ($res['status'] >= 200 && $res['status'] < 300) {
echo json_encode([
'puzzles' => $res['data'] ?? [],
'date' => $today
]);
} else {
echo json_encode(['puzzles' => [], 'date' => $today]);
}
} elseif ($action === 'streak') {
// Get player's current streak
$profileRes = supabase_rest('GET', 'profiles?select=puzzle_streak,best_puzzle_streak,puzzle_rating', [], $token);
if (!empty($profileRes['data'])) {
echo json_encode($profileRes['data'][0]);
} else {
echo json_encode(['puzzle_streak' => 0, 'best_puzzle_streak' => 0, 'puzzle_rating' => 1200]);
}
} else {
// List puzzles with optional filters
$theme = $_GET['theme'] ?? '';
$rating = intval($_GET['rating'] ?? 1200);
$limit = min(intval($_GET['limit'] ?? 20), 50);
$query = 'puzzles?select=id,fen,moves,rating,themes,title';
if ($theme) {
$query .= "&themes=cs.{{$theme}}";
}
// Get puzzles close to player's rating (within 200 range)
$ratingMin = $rating - 200;
$ratingMax = $rating + 200;
$query .= "&rating=gte.{$ratingMin}&rating=lte.{$ratingMax}";
$query .= "&order=rating.asc&limit={$limit}";
$res = supabase_rest('GET', $query, [], $token);
if ($res['status'] >= 200 && $res['status'] < 300) {
echo json_encode(['puzzles' => $res['data'] ?? []]);
} else {
echo json_encode(['puzzles' => []]);
}
}
exit;
}
// POST requests
$input = json_decode(file_get_contents('php://input'), true);
$action = $input['action'] ?? '';
switch ($action) {
case 'attempt':
$puzzleId = $input['puzzle_id'] ?? '';
$solved = $input['solved'] ?? false;
$timeMs = intval($input['time_ms'] ?? 0);
if (!$puzzleId) {
http_response_code(400);
echo json_encode(['error' => 'missing puzzle_id']);
exit;
}
// Get player's current puzzle_rating
$profileRes = supabase_rest('GET', 'profiles?select=id,puzzle_rating,puzzle_streak,best_puzzle_streak', [], $token);
$profile = $profileRes['data'][0] ?? null;
if (!$profile) {
http_response_code(404);
echo json_encode(['error' => 'profile not found']);
exit;
}
$playerRating = $profile['puzzle_rating'] ?? 1200;
$currentStreak = $profile['puzzle_streak'] ?? 0;
$bestStreak = $profile['best_puzzle_streak'] ?? 0;
// Get puzzle rating
$puzzleRes = supabase_rest('GET', "puzzles?id=eq.{$puzzleId}&select=rating", [], $token);
$puzzleRating = $puzzleRes['data'][0]['rating'] ?? 1200;
// ELO calculation (K-factor = 32 for puzzles)
$K = 32;
$expected = 1 / (1 + pow(10, ($puzzleRating - $playerRating) / 400));
$score = $solved ? 1 : 0;
$newRating = round($playerRating + $K * ($score - $expected));
// Update streak
$newStreak = $solved ? $currentStreak + 1 : 0;
$newBestStreak = max($bestStreak, $newStreak);
// Record attempt
$attemptData = [
'puzzle_id' => $puzzleId,
'solved' => $solved,
'time_ms' => $timeMs,
'rating_before' => $playerRating,
'rating_after' => $newRating
];
supabase_rest('POST', 'puzzle_attempts', $attemptData, $token);
// Update profile
supabase_rest('PATCH', "profiles?id=eq.{$profile['id']}", [
'puzzle_rating' => $newRating,
'puzzle_streak' => $newStreak,
'best_puzzle_streak' => $newBestStreak
], $token);
// Award coins for solving
$coinsAwarded = 0;
if ($solved) {
$coinsAwarded = 10;
if ($newStreak >= 5) $coinsAwarded = 20;
if ($newStreak >= 10) $coinsAwarded = 30;
supabase_rest('POST', 'rpc/add_coins', ['amount' => $coinsAwarded], $token);
}
echo json_encode([
'ok' => true,
'rating_before' => $playerRating,
'rating_after' => $newRating,
'rating_change' => $newRating - $playerRating,
'streak' => $newStreak,
'best_streak' => $newBestStreak,
'coins_awarded' => $coinsAwarded
]);
break;
case 'streak':
// Reset/get streak info
$profileRes = supabase_rest('GET', 'profiles?select=puzzle_streak,best_puzzle_streak,puzzle_rating', [], $token);
if (!empty($profileRes['data'])) {
echo json_encode($profileRes['data'][0]);
} else {
echo json_encode(['puzzle_streak' => 0, 'best_puzzle_streak' => 0, 'puzzle_rating' => 1200]);
}
break;
default:
http_response_code(400);
echo json_encode(['error' => 'invalid action']);
}
...@@ -29,6 +29,10 @@ if ($route === '' || $route === 'home') { ...@@ -29,6 +29,10 @@ if ($route === '' || $route === 'home') {
require 'pages/shop.php'; require 'pages/shop.php';
} elseif ($route === 'achievements') { } elseif ($route === 'achievements') {
require 'pages/achievements.php'; require 'pages/achievements.php';
} elseif ($route === 'analysis') {
require 'pages/analysis.php';
} elseif ($route === 'puzzles') {
require 'pages/puzzles.php';
} elseif ($route === 'notifications') { } elseif ($route === 'notifications') {
require 'pages/notifications.php'; require 'pages/notifications.php';
} elseif ($route === 'settings') { } elseif ($route === 'settings') {
......
<?php
$pageTitle = 'EL3AB - تحليل المباراة';
$extraCss = '/public/css/chessboard.css';
?>
<?php require __DIR__ . '/../includes/header.php'; ?>
<div class="analysis-page" id="analysis-page">
<!-- Loading state -->
<div class="analysis-loading" id="analysis-loading">
<div class="thinking">
<div class="thinking-dots">
<span></span><span></span><span></span>
</div>
<span>تحميل المباراة...</span>
</div>
</div>
<!-- Main content (hidden until loaded) -->
<div class="analysis-content" id="analysis-content" style="display:none;">
<!-- Header with game info -->
<div class="analysis-header">
<div class="analysis-players">
<div class="analysis-player">
<svg class="icon-sm"><use href="/public/icons/sprite.svg#icon-profile"></use></svg>
<span id="analysis-player-name">اللاعب</span>
<span class="text-muted text-sm" id="analysis-player-accuracy"></span>
</div>
<span class="text-muted">ضد</span>
<div class="analysis-player">
<svg class="icon-sm"><use href="/public/icons/sprite.svg#icon-bot"></use></svg>
<span id="analysis-opponent-name">الخصم</span>
<span class="text-muted text-sm" id="analysis-opponent-accuracy"></span>
</div>
</div>
<div class="analysis-opening" id="analysis-opening"></div>
</div>
<!-- Board + Eval Bar -->
<div class="analysis-board-section">
<!-- Eval bar (vertical, left side) -->
<div class="eval-bar" id="eval-bar">
<div class="eval-bar-fill" id="eval-bar-fill"></div>
<div class="eval-bar-value" id="eval-bar-value">0.0</div>
</div>
<!-- Board -->
<div class="board-wrapper">
<div class="board" id="board"></div>
</div>
</div>
<!-- Move navigation -->
<div class="analysis-nav">
<button class="btn btn-ghost btn-sm" id="btn-first" title="البداية">
<svg class="icon" style="transform:scaleX(-1)"><use href="/public/icons/sprite.svg#icon-arrows"></use></svg>
</button>
<button class="btn btn-ghost btn-sm" id="btn-prev" title="السابق">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-arrow-right"></use></svg>
</button>
<button class="btn btn-ghost btn-sm" id="btn-next" title="التالي">
<svg class="icon" style="transform:scaleX(-1)"><use href="/public/icons/sprite.svg#icon-arrow-right"></use></svg>
</button>
<button class="btn btn-ghost btn-sm" id="btn-last" title="النهاية">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-arrows"></use></svg>
</button>
<button class="btn btn-ghost btn-sm" onclick="Board.flip()">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-arrows"></use></svg>
اقلب
</button>
</div>
<!-- Move list with classifications -->
<div class="analysis-moves" id="analysis-moves"></div>
<!-- Eval graph (canvas chart) -->
<div class="analysis-section">
<h3 class="analysis-section-title">
<svg class="icon-sm"><use href="/public/icons/sprite.svg#icon-star"></use></svg>
رسم التقييم
</h3>
<div class="eval-graph-wrapper">
<canvas id="eval-graph" width="600" height="150"></canvas>
</div>
</div>
<!-- Time per move chart -->
<div class="analysis-section">
<h3 class="analysis-section-title">
<svg class="icon-sm"><use href="/public/icons/sprite.svg#icon-clock"></use></svg>
الوقت لكل نقلة
</h3>
<div class="eval-graph-wrapper">
<canvas id="time-graph" width="600" height="120"></canvas>
</div>
</div>
<!-- Critical moments -->
<div class="analysis-section">
<h3 class="analysis-section-title">
<svg class="icon-sm" style="color:var(--error)"><use href="/public/icons/sprite.svg#icon-fire"></use></svg>
اللحظات الحاسمة
</h3>
<div class="critical-moments" id="critical-moments"></div>
</div>
<!-- Action buttons -->
<div class="analysis-actions">
<button class="btn btn-cyan btn-sm" id="btn-analyze" onclick="Analysis.startAnalysis()">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-star"></use></svg>
تحليل بالمحرك
</button>
<button class="btn btn-ghost btn-sm" id="btn-review-mistakes" onclick="Analysis.reviewMistakes()">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-search"></use></svg>
مراجعة الاخطاء
</button>
<button class="btn btn-ghost btn-sm" onclick="Analysis.exportPGN()">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-send"></use></svg>
تصدير PGN
</button>
<button class="btn btn-ghost btn-sm" onclick="Analysis.copyFEN()">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-edit"></use></svg>
نسخ FEN
</button>
</div>
<!-- Analysis progress -->
<div class="analysis-progress" id="analysis-progress" style="display:none;">
<div class="progress-bar">
<div class="progress-bar-fill" id="analysis-progress-fill"></div>
</div>
<span class="text-sm text-muted" id="analysis-progress-text">جاري التحليل...</span>
</div>
</div>
</div>
<style>
.analysis-page {
max-width: 100%;
}
.analysis-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 300px;
}
.analysis-header {
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 12px 16px;
margin-bottom: 12px;
}
.analysis-players {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.analysis-player {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
}
.analysis-opening {
margin-top: 8px;
font-size: 12px;
color: var(--text-3);
font-family: var(--font-en);
}
.analysis-board-section {
display: flex;
gap: 8px;
align-items: stretch;
margin-bottom: 12px;
}
/* Eval bar */
.eval-bar {
width: 24px;
min-height: 100%;
background: #333;
border-radius: var(--radius-sm);
position: relative;
overflow: hidden;
border: 1px solid var(--border);
flex-shrink: 0;
}
.eval-bar-fill {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 50%;
background: #f0f0f0;
transition: height 0.3s ease;
}
.eval-bar-value {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 9px;
font-weight: 700;
font-family: var(--font-en);
color: var(--text-1);
writing-mode: vertical-lr;
text-orientation: mixed;
z-index: 1;
text-shadow: 0 0 3px rgba(0,0,0,0.8);
}
.analysis-board-section .board-wrapper {
flex: 1;
}
/* Navigation */
.analysis-nav {
display: flex;
gap: 6px;
justify-content: center;
margin-bottom: 12px;
flex-wrap: wrap;
}
/* Move list */
.analysis-moves {
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 12px;
max-height: 200px;
overflow-y: auto;
font-family: var(--font-en);
font-size: 13px;
direction: ltr;
margin-bottom: 16px;
}
.analysis-move-pair {
display: flex;
gap: 4px;
padding: 2px 0;
align-items: center;
}
.analysis-move-number {
color: var(--text-3);
min-width: 28px;
}
.analysis-move {
padding: 2px 6px;
border-radius: 4px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 2px;
}
.analysis-move:hover { background: var(--bg-3); }
.analysis-move.current { background: var(--cyan); color: var(--text-inverse); }
/* Move classifications */
.move-class {
font-size: 10px;
font-weight: 700;
margin-right: 2px;
}
.move-class-brilliant { color: #00bcd4; }
.move-class-great { color: #2196f3; }
.move-class-good { color: #4caf50; }
.move-class-book { color: #9e9e9e; }
.move-class-inaccuracy { color: #ff9800; }
.move-class-mistake { color: #f44336; }
.move-class-blunder { color: #d32f2f; }
/* Sections */
.analysis-section {
margin-bottom: 16px;
}
.analysis-section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
}
/* Eval graph */
.eval-graph-wrapper {
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 8px;
overflow-x: auto;
}
.eval-graph-wrapper canvas {
width: 100%;
height: auto;
display: block;
}
/* Critical moments */
.critical-moments {
display: flex;
flex-direction: column;
gap: 8px;
}
.critical-moment {
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 10px 14px;
cursor: pointer;
transition: background 0.2s;
display: flex;
align-items: center;
gap: 10px;
}
.critical-moment:hover { background: var(--bg-3); }
.critical-moment-move {
font-family: var(--font-en);
font-size: 14px;
font-weight: 600;
}
.critical-moment-eval {
font-family: var(--font-en);
font-size: 12px;
color: var(--text-3);
}
/* Actions */
.analysis-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 16px;
}
/* Progress bar */
.analysis-progress {
margin-bottom: 16px;
}
.progress-bar {
width: 100%;
height: 6px;
background: var(--bg-3);
border-radius: 3px;
overflow: hidden;
margin-bottom: 6px;
}
.progress-bar-fill {
height: 100%;
background: var(--cyan);
border-radius: 3px;
transition: width 0.3s ease;
width: 0%;
}
</style>
<script src="/public/js/chess.min.js"></script>
<script src="/public/js/board.js"></script>
<script src="/public/js/analysis.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
if (!App.isLoggedIn()) {
window.location.href = '/login';
return;
}
const params = new URLSearchParams(window.location.search);
const gameId = params.get('id');
if (!gameId) {
App.toast('لم يتم تحديد مباراة', 'error');
return;
}
Analysis.init(gameId);
});
</script>
<?php require __DIR__ . '/../includes/footer.php'; ?>
...@@ -4,9 +4,12 @@ $extraCss = '/public/css/chessboard.css'; ...@@ -4,9 +4,12 @@ $extraCss = '/public/css/chessboard.css';
?> ?>
<?php require __DIR__ . '/../includes/header.php'; ?> <?php require __DIR__ . '/../includes/header.php'; ?>
<div class="game-container" id="game-container"> <div class="game-layout" id="game-container">
<!-- Opponent info --> <!-- Board Column (eval bar + opponent/player info + board) -->
<div class="game-board-column">
<!-- Opponent info + clock -->
<div class="game-header"> <div class="game-header">
<div class="game-player"> <div class="game-player">
<div class="avatar avatar-sm" id="opponent-avatar"> <div class="avatar avatar-sm" id="opponent-avatar">
...@@ -20,9 +23,32 @@ $extraCss = '/public/css/chessboard.css'; ...@@ -20,9 +23,32 @@ $extraCss = '/public/css/chessboard.css';
<div class="game-clock" id="clock-top">10:00</div> <div class="game-clock" id="clock-top">10:00</div>
</div> </div>
<!-- Board + Eval Bar -->
<div class="game-board-section">
<!-- Eval Bar -->
<div class="eval-bar" id="eval-bar">
<span class="eval-bar-label eval-bar-label-top" id="eval-label-top"></span>
<div class="eval-bar-fill" id="eval-bar-fill" style="height:50%;"></div>
<span class="eval-bar-label eval-bar-label-bottom" id="eval-label-bottom"></span>
</div>
<!-- Board --> <!-- Board -->
<div class="board-wrapper"> <div class="board-wrapper">
<div class="board" id="board"></div> <div class="board" id="board"></div>
<!-- Coordinate labels -->
<div class="board-coords-file" id="coords-file">
<span>a</span><span>b</span><span>c</span><span>d</span>
<span>e</span><span>f</span><span>g</span><span>h</span>
</div>
<div class="board-coords-rank" id="coords-rank">
<span>8</span><span>7</span><span>6</span><span>5</span>
<span>4</span><span>3</span><span>2</span><span>1</span>
</div>
<!-- Arrow overlay -->
<div class="board-arrows" id="board-arrows">
<svg viewBox="0 0 800 800" preserveAspectRatio="xMidYMid meet"></svg>
</div>
</div>
</div> </div>
<!-- Thinking indicator --> <!-- Thinking indicator -->
...@@ -33,7 +59,12 @@ $extraCss = '/public/css/chessboard.css'; ...@@ -33,7 +59,12 @@ $extraCss = '/public/css/chessboard.css';
<span>يفكر...</span> <span>يفكر...</span>
</div> </div>
<!-- Player info --> <!-- Pre-move indicator -->
<div class="premove-indicator" id="premove-indicator" style="display:none;">
نقلة مسبقة محجوزة
</div>
<!-- Player info + clock -->
<div class="game-header"> <div class="game-header">
<div class="game-player"> <div class="game-player">
<div class="avatar avatar-sm"> <div class="avatar avatar-sm">
...@@ -47,14 +78,74 @@ $extraCss = '/public/css/chessboard.css'; ...@@ -47,14 +78,74 @@ $extraCss = '/public/css/chessboard.css';
<div class="game-clock" id="clock-bottom">10:00</div> <div class="game-clock" id="clock-bottom">10:00</div>
</div> </div>
<!-- Status --> </div>
<div class="text-center text-sm text-muted" id="game-status">دورك</div>
<!-- Side Panel (Desktop) -->
<div class="game-side-panel" id="side-panel">
<div class="analysis-panel">
<!-- Opening name -->
<div class="opening-display" id="opening-display" style="display:none;">
<span class="opening-eco" id="opening-eco"></span>
<span class="opening-name" id="opening-name"></span>
</div>
<!-- Move list --> <!-- Move list -->
<div class="move-list" id="move-list"></div> <div class="move-list-pro" id="move-list"></div>
<!-- Game status -->
<div class="panel-status" id="game-status">دورك</div>
<!-- Controls --> <!-- Controls -->
<div class="game-controls"> <div class="panel-controls" id="game-controls">
<button class="btn btn-ghost btn-sm" onclick="Game.resign()" id="btn-resign">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-flag"></use></svg>
استسلام
</button>
<button class="btn btn-ghost btn-sm" onclick="Board.flip()">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-arrows"></use></svg>
اقلب
</button>
<button class="btn btn-ghost btn-sm" onclick="Game.toggleArrows()" id="btn-arrows">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-eye"></use></svg>
اسهم
</button>
</div>
<!-- Post-game controls (hidden initially) -->
<div class="postgame-controls" id="postgame-controls" style="display:none;">
<button class="btn btn-ghost btn-sm" onclick="Game.exportPGN()">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-download"></use></svg>
PGN
</button>
<button class="btn btn-ghost btn-sm" onclick="Game.copyFEN()">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-copy"></use></svg>
FEN
</button>
<button class="btn btn-ghost btn-sm" onclick="Game.analyze()">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-chart"></use></svg>
تحليل
</button>
</div>
</div>
</div>
<!-- Mobile Panel (shown only on mobile) -->
<div class="game-mobile-panel" id="mobile-panel">
<div class="analysis-panel">
<!-- Opening name (mobile) -->
<div class="opening-display" id="opening-display-mobile" style="display:none;">
<span class="opening-eco" id="opening-eco-mobile"></span>
<span class="opening-name" id="opening-name-mobile"></span>
</div>
<!-- Move list (mobile) -->
<div class="move-list-pro" id="move-list-mobile"></div>
<!-- Game status (mobile) -->
<div class="panel-status" id="game-status-mobile">دورك</div>
<!-- Controls (mobile) -->
<div class="panel-controls" id="game-controls-mobile">
<button class="btn btn-ghost btn-sm" onclick="Game.resign()"> <button class="btn btn-ghost btn-sm" onclick="Game.resign()">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-flag"></use></svg> <svg class="icon"><use href="/public/icons/sprite.svg#icon-flag"></use></svg>
استسلام استسلام
...@@ -63,12 +154,35 @@ $extraCss = '/public/css/chessboard.css'; ...@@ -63,12 +154,35 @@ $extraCss = '/public/css/chessboard.css';
<svg class="icon"><use href="/public/icons/sprite.svg#icon-arrows"></use></svg> <svg class="icon"><use href="/public/icons/sprite.svg#icon-arrows"></use></svg>
اقلب اقلب
</button> </button>
<button class="btn btn-ghost btn-sm" onclick="Game.toggleArrows()">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-eye"></use></svg>
اسهم
</button>
</div>
<!-- Post-game controls (mobile, hidden initially) -->
<div class="postgame-controls" id="postgame-controls-mobile" style="display:none;">
<button class="btn btn-ghost btn-sm" onclick="Game.exportPGN()">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-download"></use></svg>
PGN
</button>
<button class="btn btn-ghost btn-sm" onclick="Game.copyFEN()">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-copy"></use></svg>
FEN
</button>
<button class="btn btn-ghost btn-sm" onclick="Game.analyze()">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-chart"></use></svg>
تحليل
</button>
</div>
</div>
</div> </div>
</div> </div>
<script src="/public/js/chess.min.js"></script> <script src="/public/js/chess.min.js"></script>
<script src="/public/js/board.js"></script> <script src="/public/js/board.js"></script>
<script src="/public/js/openings.js"></script>
<script src="/public/js/game.js"></script> <script src="/public/js/game.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
......
<?php
$pageTitle = 'EL3AB - الالغاز';
$extraCss = '/public/css/chessboard.css';
?>
<?php require __DIR__ . '/../includes/header.php'; ?>
<div class="puzzles-page" id="puzzles-page">
<!-- Header -->
<div class="text-center" style="margin-bottom:16px;">
<h2 style="font-size:22px;font-weight:700;">الالغاز</h2>
<p class="text-muted text-sm">حل الالغاز وطور مستواك</p>
</div>
<!-- Stats bar -->
<div class="puzzle-stats" id="puzzle-stats">
<div class="puzzle-stat">
<svg class="icon-sm" style="color:var(--gold)"><use href="/public/icons/sprite.svg#icon-star"></use></svg>
<div>
<div class="puzzle-stat-value" id="puzzle-rating">1200</div>
<div class="puzzle-stat-label">التصنيف</div>
</div>
</div>
<div class="puzzle-stat">
<svg class="icon-sm" style="color:var(--cyan)"><use href="/public/icons/sprite.svg#icon-fire"></use></svg>
<div>
<div class="puzzle-stat-value" id="puzzle-streak">0</div>
<div class="puzzle-stat-label">السلسلة</div>
</div>
</div>
<div class="puzzle-stat">
<svg class="icon-sm" style="color:var(--purple)"><use href="/public/icons/sprite.svg#icon-trophy"></use></svg>
<div>
<div class="puzzle-stat-value" id="puzzle-best-streak">0</div>
<div class="puzzle-stat-label">افضل سلسلة</div>
</div>
</div>
</div>
<!-- Tabs -->
<div class="puzzle-tabs" id="puzzle-tabs">
<button class="tab active" data-tab="daily">اليومي</button>
<button class="tab" data-tab="streak">السلسلة</button>
<button class="tab" data-tab="themes">المواضيع</button>
<button class="tab" data-tab="rush">سباق</button>
</div>
<!-- Daily tab content -->
<div class="puzzle-tab-content" id="tab-daily">
<div class="card" style="margin-bottom:12px;">
<div class="card-body">
<p style="font-size:14px;font-weight:600;margin-bottom:8px;">
<svg class="icon-sm" style="color:var(--gold)"><use href="/public/icons/sprite.svg#icon-star"></use></svg>
الالغاز اليومية
</p>
<p class="text-sm text-muted" style="margin-bottom:12px;">3 الغاز جديدة كل يوم</p>
<div class="daily-progress" id="daily-progress">
<div class="daily-dot"></div>
<div class="daily-dot"></div>
<div class="daily-dot"></div>
</div>
</div>
</div>
</div>
<!-- Streak tab content -->
<div class="puzzle-tab-content" id="tab-streak" style="display:none;">
<div class="card" style="margin-bottom:12px;">
<div class="card-body text-center">
<p style="font-size:14px;font-weight:600;margin-bottom:4px;">وضع السلسلة</p>
<p class="text-sm text-muted">حل اكبر عدد على التوالي</p>
</div>
</div>
</div>
<!-- Themes tab content -->
<div class="puzzle-tab-content" id="tab-themes" style="display:none;">
<div class="puzzle-themes-grid" id="puzzle-themes-grid">
<div class="puzzle-theme-card" data-theme="fork">
<svg class="icon" style="color:var(--cyan)"><use href="/public/icons/sprite.svg#icon-sword"></use></svg>
<span>شوكة</span>
</div>
<div class="puzzle-theme-card" data-theme="pin">
<svg class="icon" style="color:var(--gold)"><use href="/public/icons/sprite.svg#icon-shield"></use></svg>
<span>تثبيت</span>
</div>
<div class="puzzle-theme-card" data-theme="skewer">
<svg class="icon" style="color:var(--error)"><use href="/public/icons/sprite.svg#icon-arrow-right"></use></svg>
<span>سيخ</span>
</div>
<div class="puzzle-theme-card" data-theme="mate">
<svg class="icon" style="color:var(--purple)"><use href="/public/icons/sprite.svg#icon-crown"></use></svg>
<span>مات</span>
</div>
<div class="puzzle-theme-card" data-theme="endgame">
<svg class="icon" style="color:var(--text-2)"><use href="/public/icons/sprite.svg#icon-flag"></use></svg>
<span>نهايات</span>
</div>
<div class="puzzle-theme-card" data-theme="opening">
<svg class="icon" style="color:var(--success)"><use href="/public/icons/sprite.svg#icon-board"></use></svg>
<span>افتتاحيات</span>
</div>
</div>
</div>
<!-- Rush tab content -->
<div class="puzzle-tab-content" id="tab-rush" style="display:none;">
<div class="card" style="margin-bottom:12px;">
<div class="card-body text-center">
<p style="font-size:14px;font-weight:600;margin-bottom:4px;">سباق الالغاز</p>
<p class="text-sm text-muted" style="margin-bottom:12px;">حل اكبر عدد خلال 3 دقائق</p>
<div class="rush-timer" id="rush-timer" style="display:none;">
<svg class="icon-sm" style="color:var(--error)"><use href="/public/icons/sprite.svg#icon-clock"></use></svg>
<span id="rush-time">3:00</span>
<span class="text-muted">|</span>
<span id="rush-count">0 حل</span>
</div>
<button class="btn btn-gold" id="btn-start-rush" onclick="Puzzles.startRush()">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-fire"></use></svg>
ابدأ السباق
</button>
</div>
</div>
</div>
<!-- Puzzle board area (appears when solving) -->
<div class="puzzle-board-area" id="puzzle-board-area" style="display:none;">
<!-- Puzzle info -->
<div class="puzzle-info">
<div class="puzzle-info-rating">
<svg class="icon-sm" style="color:var(--gold)"><use href="/public/icons/sprite.svg#icon-star"></use></svg>
<span id="current-puzzle-rating">1200</span>
</div>
<div class="puzzle-info-turn" id="puzzle-turn">دور الابيض</div>
</div>
<!-- Board -->
<div class="board-wrapper">
<div class="board" id="board"></div>
</div>
<!-- Puzzle status -->
<div class="puzzle-status" id="puzzle-status">
<span>جد افضل نقلة</span>
</div>
<!-- Puzzle result overlay -->
<div class="puzzle-result" id="puzzle-result" style="display:none;">
<div class="puzzle-result-icon" id="puzzle-result-icon"></div>
<div class="puzzle-result-text" id="puzzle-result-text"></div>
<button class="btn btn-cyan btn-sm" id="btn-next-puzzle" onclick="Puzzles.nextPuzzle()">
التالي
<svg class="icon" style="transform:scaleX(-1)"><use href="/public/icons/sprite.svg#icon-arrow-right"></use></svg>
</button>
</div>
</div>
</div>
<style>
.puzzles-page {
max-width: 100%;
}
/* Stats */
.puzzle-stats {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.puzzle-stat {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 10px 12px;
}
.puzzle-stat-value {
font-size: 16px;
font-weight: 700;
font-family: var(--font-en);
}
.puzzle-stat-label {
font-size: 11px;
color: var(--text-3);
}
/* Tabs */
.puzzle-tabs {
display: flex;
gap: 4px;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 4px;
margin-bottom: 16px;
}
.puzzle-tabs .tab {
flex: 1;
padding: 8px 12px;
font-size: 13px;
font-weight: 600;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--text-2);
cursor: pointer;
transition: all 0.2s;
}
.puzzle-tabs .tab.active {
background: var(--cyan);
color: var(--text-inverse);
}
/* Daily progress dots */
.daily-progress {
display: flex;
gap: 8px;
justify-content: center;
}
.daily-dot {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--bg-3);
border: 2px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
}
.daily-dot.solved {
background: var(--success);
border-color: var(--success);
}
.daily-dot.current {
border-color: var(--cyan);
animation: pulse 1.5s ease infinite;
}
@keyframes pulse {
50% { transform: scale(1.1); }
}
/* Themes grid */
.puzzle-themes-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.puzzle-theme-card {
display: flex;
align-items: center;
gap: 10px;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 14px 16px;
cursor: pointer;
transition: background 0.2s;
font-size: 14px;
font-weight: 600;
}
.puzzle-theme-card:hover { background: var(--bg-3); }
/* Rush timer */
.rush-timer {
display: flex;
align-items: center;
gap: 8px;
justify-content: center;
font-family: var(--font-en);
font-size: 18px;
font-weight: 700;
margin-bottom: 12px;
}
/* Puzzle board area */
.puzzle-board-area {
margin-top: 16px;
}
.puzzle-info {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--radius-md);
margin-bottom: 8px;
}
.puzzle-info-rating {
display: flex;
align-items: center;
gap: 4px;
font-family: var(--font-en);
font-weight: 600;
}
.puzzle-info-turn {
font-size: 13px;
color: var(--text-2);
}
.puzzle-status {
text-align: center;
padding: 10px;
font-size: 14px;
font-weight: 600;
color: var(--text-2);
}
.puzzle-status.correct {
color: var(--success);
}
.puzzle-status.wrong {
color: var(--error);
}
/* Puzzle result */
.puzzle-result {
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 16px;
text-align: center;
margin-top: 8px;
}
.puzzle-result-icon {
font-size: 32px;
margin-bottom: 8px;
}
.puzzle-result-text {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
}
</style>
<script src="/public/js/chess.min.js"></script>
<script src="/public/js/board.js"></script>
<script src="/public/js/puzzles.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
if (!App.isLoggedIn()) {
window.location.href = '/login';
return;
}
Puzzles.init();
});
</script>
<?php require __DIR__ . '/../includes/footer.php'; ?>
...@@ -114,6 +114,11 @@ ...@@ -114,6 +114,11 @@
} }
.square.last-move { background: rgba(231, 168, 50, 0.3) !important; } .square.last-move { background: rgba(231, 168, 50, 0.3) !important; }
.square.in-check { background: rgba(239, 68, 68, 0.5) !important; } .square.in-check { background: rgba(239, 68, 68, 0.5) !important; }
.square.premove-from { background: rgba(21, 180, 240, 0.35) !important; }
.square.premove-to { background: rgba(21, 180, 240, 0.35) !important; }
.square.highlight-green { background: rgba(21, 180, 90, 0.45) !important; }
.square.highlight-red { background: rgba(220, 50, 50, 0.45) !important; }
.square.highlight-yellow { background: rgba(220, 180, 30, 0.45) !important; }
/* Pieces */ /* Pieces */
.piece { .piece {
...@@ -236,6 +241,28 @@ ...@@ -236,6 +241,28 @@
color: var(--text-2); color: var(--text-2);
} }
.game-result-stats {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 12px;
font-family: var(--font-en);
font-size: 13px;
width: 180px;
}
.game-result-stats .stat-row {
display: flex;
justify-content: space-between;
padding: 3px 0;
color: var(--text-2);
}
.game-result-stats .stat-row span:last-child {
font-weight: 600;
color: var(--text-1, #fff);
}
/* Thinking indicator */ /* Thinking indicator */
.thinking { .thinking {
display: flex; display: flex;
...@@ -298,3 +325,419 @@ ...@@ -298,3 +325,419 @@
} }
.promotion-piece:hover { background: var(--cyan); } .promotion-piece:hover { background: var(--cyan); }
/* ============================================
PRO FEATURES
============================================ */
/* Eval Bar */
.eval-bar {
width: 24px;
min-width: 24px;
height: 100%;
background: #333;
border-radius: var(--radius-sm);
overflow: hidden;
position: relative;
border: 1px solid var(--border);
}
.eval-bar-fill {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: #fff;
transition: height 0.5s ease;
height: 50%;
}
.eval-bar-label {
position: absolute;
left: 0;
right: 0;
text-align: center;
font-family: var(--font-en);
font-size: 9px;
font-weight: 700;
pointer-events: none;
z-index: 1;
}
.eval-bar-label-top {
top: 4px;
color: #fff;
}
.eval-bar-label-bottom {
bottom: 4px;
color: #333;
}
/* Arrow Overlay */
.board-arrows {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 5;
}
.board-arrows svg {
width: 100%;
height: 100%;
position: absolute;
inset: 0;
}
.board-arrows svg line,
.board-arrows svg polygon {
opacity: 0.7;
}
/* Square Highlights */
.square-highlight-green {
background: rgba(21, 215, 100, 0.4) !important;
}
.square-highlight-red {
background: rgba(239, 68, 68, 0.45) !important;
}
.square-highlight-yellow {
background: rgba(255, 200, 0, 0.4) !important;
}
/* Pre-move Ghost Piece */
.piece-ghost {
opacity: 0.45;
pointer-events: none;
filter: grayscale(0.3);
}
/* Move Classification Badges */
.move-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
font-size: 9px;
font-weight: 800;
font-family: var(--font-en);
margin-inline-start: 4px;
vertical-align: middle;
flex-shrink: 0;
}
.move-brilliant {
background: #26c6da;
color: #fff;
}
.move-great {
background: #66bb6a;
color: #fff;
}
.move-good {
background: #81c784;
color: #fff;
}
.move-inaccuracy {
background: #fdd835;
color: #333;
}
.move-mistake {
background: #ef6c00;
color: #fff;
}
.move-blunder {
background: #e53935;
color: #fff;
}
/* Analysis Panel */
.analysis-panel {
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.analysis-panel-header {
padding: 10px 14px;
border-bottom: 1px solid var(--border);
font-size: 13px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.analysis-eval-graph {
width: 100%;
height: 80px;
min-height: 60px;
background: var(--bg-3);
border-bottom: 1px solid var(--border);
position: relative;
}
.analysis-eval-graph canvas {
width: 100%;
height: 100%;
display: block;
}
/* Puzzle UI */
.puzzle-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--radius-md);
}
.puzzle-streak {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-en);
font-size: 18px;
font-weight: 700;
color: var(--gold);
}
.puzzle-timer {
font-family: var(--font-en);
font-size: 16px;
font-weight: 600;
color: var(--text-2);
padding: 4px 10px;
background: var(--bg-3);
border-radius: var(--radius-sm);
}
/* Pro Move List */
.move-list-pro {
flex: 1;
overflow-y: auto;
font-family: var(--font-en);
font-size: 13px;
direction: ltr;
text-align: left;
padding: 8px 0;
min-height: 0;
max-height: 320px;
}
.move-list-pro .move-pair {
display: flex;
align-items: center;
padding: 3px 12px;
gap: 2px;
}
.move-list-pro .move-pair:nth-child(odd) {
background: var(--bg-3);
}
.move-list-pro .move-number {
color: var(--text-3);
min-width: 30px;
font-size: 12px;
}
.move-list-pro .move {
padding: 3px 8px;
border-radius: 4px;
cursor: pointer;
min-width: 54px;
text-align: center;
}
.move-list-pro .move:hover {
background: var(--bg-hover, rgba(255,255,255,0.08));
}
.move-list-pro .move.current {
background: var(--cyan);
color: var(--text-inverse);
}
/* Opening Display */
.opening-display {
padding: 8px 14px;
border-bottom: 1px solid var(--border);
font-size: 12px;
color: var(--text-2);
display: flex;
align-items: center;
gap: 6px;
}
.opening-eco {
font-family: var(--font-en);
font-weight: 700;
color: var(--cyan);
font-size: 11px;
}
.opening-name {
font-weight: 500;
}
/* Game Status in Panel */
.panel-status {
padding: 10px 14px;
border-top: 1px solid var(--border);
font-size: 13px;
text-align: center;
color: var(--text-2);
}
/* Post-game Controls */
.postgame-controls {
display: flex;
gap: 6px;
padding: 10px 14px;
border-top: 1px solid var(--border);
flex-wrap: wrap;
}
.postgame-controls .btn {
flex: 1;
min-width: 0;
font-size: 12px;
padding: 6px 8px;
}
/* Panel Controls Row */
.panel-controls {
display: flex;
gap: 6px;
padding: 10px 14px;
border-top: 1px solid var(--border);
}
.panel-controls .btn {
flex: 1;
font-size: 12px;
padding: 6px 8px;
}
/* Pre-move indicator */
.premove-indicator {
font-size: 11px;
color: var(--cyan);
padding: 4px 14px;
border-top: 1px solid var(--border);
}
/* ============================================
PRO GAME LAYOUT (Desktop + Mobile)
============================================ */
.game-layout {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 100%;
}
.game-board-section {
display: flex;
gap: 8px;
align-items: stretch;
}
.game-side-panel {
display: none;
flex-direction: column;
width: 280px;
min-width: 260px;
max-height: calc(100vw - 60px);
}
/* Eval graph canvas container */
.eval-graph-container {
position: relative;
width: 100%;
height: 80px;
background: var(--bg-3);
}
.eval-graph-container canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
/* ============================================
RESPONSIVE
============================================ */
@media (min-width: 768px) {
.game-layout {
flex-direction: row;
align-items: flex-start;
gap: 16px;
}
.game-board-column {
flex: 0 0 auto;
}
.game-side-panel {
display: flex;
flex: 1;
max-width: 320px;
max-height: 560px;
}
.game-mobile-panel {
display: none;
}
.board-wrapper {
max-width: 560px;
}
}
@media (max-width: 767px) {
.game-layout {
flex-direction: column;
}
.game-side-panel {
display: none;
}
.game-mobile-panel {
display: flex;
flex-direction: column;
}
.game-mobile-panel .analysis-panel {
max-height: 260px;
}
.game-mobile-panel .move-list-pro {
max-height: 160px;
}
.eval-bar {
width: 18px;
min-width: 18px;
}
}
...@@ -133,4 +133,30 @@ ...@@ -133,4 +133,30 @@
<path d="M9.879 16.121A3 3 0 1012.015 11L11 14H9c0 .768.293 1.536.879 2.121z"/> <path d="M9.879 16.121A3 3 0 1012.015 11L11 14H9c0 .768.293 1.536.879 2.121z"/>
</symbol> </symbol>
<symbol id="icon-eye" viewBox="0 0 24 24">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</symbol>
<symbol id="icon-download" viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/>
</symbol>
<symbol id="icon-copy" viewBox="0 0 24 24">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
</symbol>
<symbol id="icon-chart" viewBox="0 0 24 24">
<path d="M18 20V10M12 20V4M6 20v-6"/>
</symbol>
<symbol id="icon-puzzle" viewBox="0 0 24 24">
<path d="M14.5 2.5a2.5 2.5 0 015 0v1h1a2 2 0 012 2v3.5h-1a2.5 2.5 0 000 5h1V17a2 2 0 01-2 2h-3.5v-1a2.5 2.5 0 00-5 0v1H7.5a2 2 0 01-2-2v-3.5h1a2.5 2.5 0 000-5h-1V5a2 2 0 012-2h3.5v-.5z"/>
</symbol>
<symbol id="icon-brain" viewBox="0 0 24 24">
<path d="M12 2C9.5 2 7.5 4 7.5 6.5c0 1-.5 2-1.5 2.5-1.5 1-2.5 2.5-2.5 4.5C3.5 16 5.5 18 8 18h1v3h6v-3h1c2.5 0 4.5-2 4.5-4.5 0-2-1-3.5-2.5-4.5-1-.5-1.5-1.5-1.5-2.5C16.5 4 14.5 2 12 2z"/>
</symbol>
</svg> </svg>
// EL3AB Analysis Controller - Post-game review with engine evaluation
const Analysis = {
chess: null,
gameData: null,
gameId: null,
moves: [],
positions: [], // FEN at each position (index 0 = start)
evaluations: [],
currentMoveIndex: -1, // -1 = starting position
playerColor: 'w',
isAnalyzing: false,
mistakeIndices: [],
// ECO Opening lookup (top 100)
ECO: {
'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq': 'B00 King\'s Pawn',
'rnbqkbnr/pppppppp/8/8/3P4/8/PPP1PPPP/RNBQKBNR b KQkq': 'D00 Queen\'s Pawn',
'rnbqkbnr/pppppppp/8/8/2P5/8/PP1PPPPP/RNBQKBNR b KQkq': 'A10 English Opening',
'rnbqkbnr/pppppppp/8/8/8/5N2/PPPPPPPP/RNBQKB1R b KQkq': 'A04 Reti Opening',
'rnbqkbnr/pppp1ppp/4p3/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq': 'C00 French Defense',
'rnbqkbnr/pp1ppppp/2p5/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq': 'B10 Caro-Kann',
'rnbqkbnr/pppppp1p/6p1/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq': 'B06 Modern Defense',
'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3': 'B00 King\'s Pawn Opening',
'rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq': 'C20 Open Game',
'rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq': 'C40 King\'s Knight Opening',
'r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq': 'C44 Open Game',
'r1bqkbnr/pppp1ppp/2n5/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R b KQkq': 'C50 Italian Game',
'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R w KQkq': 'C54 Italian Game: Giuoco Piano',
'r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq': 'C40 King\'s Knight',
'r1bqkbnr/pppp1ppp/2n5/1B2p3/4P3/5N2/PPPP1PPP/RNBQK2R b KQkq': 'C60 Ruy Lopez',
'r1bqkb1r/pppp1ppp/2n2n2/1B2p3/4P3/5N2/PPPP1PPP/RNBQK2R w KQkq': 'C65 Ruy Lopez: Berlin',
'r1bqkbnr/1ppp1ppp/p1n5/1B2p3/4P3/5N2/PPPP1PPP/RNBQK2R w KQkq': 'C70 Ruy Lopez: Morphy',
'rnbqkb1r/pppppppp/5n2/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq': 'B02 Alekhine Defense',
'rnbqkb1r/pppppppp/5n2/8/3P4/8/PPP1PPPP/RNBQKBNR w KQkq': 'A45 Indian Game',
'rnbqkb1r/pppppp1p/5np1/8/3P4/8/PPP1PPPP/RNBQKBNR w KQkq': 'A48 King\'s Indian',
'rnbqkb1r/pppppp1p/5np1/8/2PP4/8/PP2PPPP/RNBQKBNR b KQkq': 'E60 King\'s Indian Defense',
'rnbqk2r/ppppppbp/5np1/8/2PP4/2N5/PP2PPPP/R1BQKBNR b KQkq': 'E61 King\'s Indian',
'rnbqk2r/ppp1ppbp/3p1np1/8/2PP4/2N2N2/PP2PPPP/R1BQKB1R b KQkq': 'E70 King\'s Indian: Classical',
'rnbqkb1r/pppp1ppp/4pn2/8/3P4/8/PPP1PPPP/RNBQKBNR w KQkq': 'A46 Indian Defense',
'rnbqkb1r/pppp1ppp/4pn2/8/2PP4/8/PP2PPPP/RNBQKBNR b KQkq': 'E00 Catalan/Indian',
'rnbqkb1r/p1pp1ppp/1p2pn2/8/2PP4/8/PP2PPPP/RNBQKBNR w KQkq': 'A40 Queen\'s Indian',
'rnbqkb1r/pppp1ppp/4pn2/8/2PP4/5N2/PP2PPPP/RNBQKB1R b KQkq': 'E10 Queen\'s Pawn',
'rnbqkb1r/pppp1ppp/4pn2/8/2PP4/2N5/PP2PPPP/R1BQKBNR b KQkq': 'E20 Nimzo-Indian',
'rnbqk2r/pppp1ppp/4pn2/8/1bPP4/2N5/PP2PPPP/R1BQKBNR w KQkq': 'E21 Nimzo-Indian Defense',
'rnbqkbnr/ppp1pppp/8/3p4/3P4/8/PPP1PPPP/RNBQKBNR w KQkq': 'D00 Queen\'s Pawn Game',
'rnbqkbnr/ppp1pppp/8/3p4/2PP4/8/PP2PPPP/RNBQKBNR b KQkq': 'D06 Queen\'s Gambit',
'rnbqkbnr/ppp2ppp/4p3/3p4/2PP4/8/PP2PPPP/RNBQKBNR w KQkq': 'D30 Queen\'s Gambit Declined',
'rnbqkbnr/ppp1pppp/8/3p4/2PP4/8/PP2PPPP/RNBQKBNR b KQkq c3': 'D06 Queen\'s Gambit',
'rnbqkbnr/ppp2ppp/8/3pp3/2PP4/8/PP2PPPP/RNBQKBNR w KQkq': 'D06 QGD: Albin Counter',
'rnbqkb1r/ppp1pppp/5n2/3p4/2PP4/8/PP2PPPP/RNBQKBNR w KQkq': 'D06 QGD',
'rnbqkb1r/ppp1pppp/5n2/3p4/2PP4/5N2/PP2PPPP/RNBQKB1R b KQkq': 'D10 Slav Defense',
'rnbqkb1r/pp2pppp/2p2n2/3p4/2PP4/5N2/PP2PPPP/RNBQKB1R w KQkq': 'D11 Slav',
'rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6': 'C20 King\'s Pawn Game',
'rnbqkbnr/pppp1ppp/8/4p3/2P5/8/PP1PPPPP/RNBQKBNR w KQkq': 'A20 English: Reversed Sicilian',
'rnbqkbnr/pp1ppppp/8/2p5/4P3/8/PPPP1PPP/RNBQKBNR w KQkq': 'B20 Sicilian Defense',
'rnbqkbnr/pp1ppppp/8/2p5/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq': 'B27 Sicilian',
'rnbqkbnr/pp1ppppp/3p4/2p5/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq': 'B50 Sicilian',
'rnbqkbnr/pp1ppppp/8/2p5/3PP3/8/PPP2PPP/RNBQKBNR b KQkq': 'B21 Sicilian: Smith-Morra',
'rnbqkbnr/pp2pppp/3p4/2p5/3PP3/5N2/PPP2PPP/RNBQKB1R b KQkq': 'B50 Sicilian',
'rnbqkbnr/pp2pppp/3p4/8/3pP3/5N2/PPP2PPP/RNBQKB1R w KQkq': 'B54 Sicilian: Open',
'r1bqkbnr/pp2pppp/2np4/8/3NP3/8/PPP2PPP/RNBQKB1R w KQkq': 'B40 Sicilian: Open Variation',
'rnbqkbnr/pp2pppp/3p4/2p5/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq': 'B40 Sicilian',
'r1bqkbnr/pp1ppppp/2n5/2p5/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq': 'B30 Sicilian',
'r1bqkb1r/pp1ppppp/2n2n2/2p5/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq': 'B30 Sicilian',
'rnbqkbnr/pp1p1ppp/4p3/2p5/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq': 'B40 Sicilian',
'rnbqkb1r/pp1ppppp/5n2/2p5/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq': 'B27 Sicilian',
'r1bqkb1r/pp2pppp/2np1n2/8/3NP3/2N5/PPP2PPP/R1BQKB1R w KQkq': 'B60 Sicilian Richter-Rauzer',
'rnbqkb1r/1p2pppp/p2p1n2/8/3NP3/2N5/PPP2PPP/R1BQKB1R w KQkq': 'B90 Sicilian Najdorf',
'r1b1kb1r/pp3ppp/1qnppn2/8/3NP3/2N1B3/PPPQ1PPP/R3KB1R w KQkq': 'B80 Sicilian Scheveningen',
'r1bqkb1r/pp3ppp/2nppn2/8/3NP3/2N5/PPP1BPPP/R1BQK2R b KQkq': 'B80 Sicilian',
'rnbqkbnr/pppp1p1p/8/6p1/4Pp2/8/PPPP1PPP/RNBQKBNR w KQkq': 'C30 King\'s Gambit',
'rnbqkbnr/pppp1ppp/8/4p3/4PP2/8/PPPP2PP/RNBQKBNR b KQkq': 'C30 King\'s Gambit',
'rnbqkbnr/ppp1pppp/8/3p4/4P3/8/PPPP1PPP/RNBQKBNR w KQkq': 'B01 Scandinavian',
'rnbqkbnr/pppp1ppp/8/8/4Pp2/8/PPPP2PP/RNBQKBNR w KQkq': 'C30 KG Accepted',
'rnbqkbnr/pppp1ppp/8/4p3/4P3/2N5/PPPP1PPP/R1BQKBNR b KQkq': 'C25 Vienna Game',
'rnbqk2r/pppp1ppp/5n2/2b1p3/2B1P3/5N2/PPPP1PPP/RNBQK2R w KQkq': 'C50 Italian: Giuoco Piano',
'rnbqkb1r/pppp1ppp/5n2/4p3/2B1P3/8/PPPP1PPP/RNBQK1NR w KQkq': 'C42 Petrov Defense',
'rnbqkb1r/pppp1ppp/5n2/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq': 'C42 Petrov Defense',
'r1bqk2r/pppp1ppp/2n2n2/2b1p3/2B1P3/5N2/PPPP1PPP/RNBQK2R w KQkq': 'C50 Italian: Two Knights',
'r1bqkbnr/pppppppp/2n5/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq': 'B00 Nimzowitsch Defense',
'rnbqkbnr/pp1ppppp/8/2p5/3P4/8/PPP1PPPP/RNBQKBNR w KQkq': 'A40 Benoni',
'rnbqkb1r/pppppp1p/5np1/8/2P5/8/PP1PPPPP/RNBQKBNR w KQkq': 'A15 English: Anglo-Indian',
'rnbqkbnr/pppppp1p/6p1/8/3P4/8/PPP1PPPP/RNBQKBNR w KQkq': 'A40 Modern',
'rnbqkbnr/pppppp1p/6p1/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq': 'B06 Modern Defense',
'rnbqkbnr/pp1ppppp/2p5/8/3P4/8/PPP1PPPP/RNBQKBNR w KQkq': 'A00 Slav Setup',
'rnbqkbnr/pppppp1p/6p1/8/8/5N2/PPPPPPPP/RNBQKB1R w KQkq': 'A04 Reti',
'rnbqkbnr/ppppp1pp/8/5p2/4P3/8/PPPP1PPP/RNBQKBNR w KQkq': 'A02 Bird\'s Opening / From\'s Gambit',
'rnbqkbnr/ppppp1pp/8/5p2/3P4/8/PPP1PPPP/RNBQKBNR w KQkq': 'A45 Dutch',
'rnbqkbnr/ppppp1pp/8/5p2/2P5/8/PP1PPPPP/RNBQKBNR w KQkq': 'A10 English vs Dutch',
'rnbqkbnr/ppppp1pp/8/5p2/8/5N2/PPPPPPPP/RNBQKB1R w KQkq': 'A04 Reti vs Dutch',
'rnbqkbnr/pppppppp/8/8/8/2N5/PPPPPPPP/R1BQKBNR b KQkq': 'A00 Van\'t Kruijs',
'rnbqkbnr/pppppppp/8/8/P7/8/1PPPPPPP/RNBQKBNR b KQkq': 'A00 Ware Opening',
'rnbqkbnr/pppppppp/8/8/1P6/8/P1PPPPPP/RNBQKBNR b KQkq': 'A00 Polish Opening',
'rnbqkbnr/pppppppp/8/8/8/1P6/P1PPPPPP/RNBQKBNR b KQkq': 'A01 Larsen\'s Opening',
'rnbqkbnr/pppppppp/8/8/8/6P1/PPPPPP1P/RNBQKBNR b KQkq': 'A00 Benko Opening',
'rnbqkbnr/pppppppp/8/8/8/P7/1PPPPPPP/RNBQKBNR b KQkq': 'A00 Anderssen Opening',
'rnbqkb1r/ppp1pppp/3p1n2/8/3P4/5N2/PPP1PPPP/RNBQKB1R w KQkq': 'A41 Old Indian',
'rnbqkb1r/ppp1pppp/3p1n2/8/2PP4/5N2/PP2PPPP/RNBQKB1R b KQkq': 'A41 Old Indian',
'rnbqk2r/ppppppbp/5np1/8/2PP4/2N2N2/PP2PPPP/R1BQKB1R b KQkq': 'E62 King\'s Indian: Fianchetto',
'rnbq1rk1/ppp1ppbp/3p1np1/8/2PPP3/2N2N2/PP3PPP/R1BQKB1R b KQ': 'E70 King\'s Indian',
'rnbq1rk1/ppppppbp/5np1/8/2PP4/2N2N2/PP2PPPP/R1BQKB1R b KQ': 'E62 King\'s Indian: Fianchetto',
'rnbqk2r/pppp1ppp/4pn2/8/1bPP4/2N2N2/PP2PPPP/R1BQKB1R b KQkq': 'E40 Nimzo-Indian',
'rnbqk2r/pppp1ppp/4pn2/8/1bPP4/2N5/PPQ1PPPP/R1B1KBNR b KQkq': 'E32 Nimzo-Indian: Classical',
'rnbqk2r/pppp1ppp/4pn2/8/1bPP4/P1N5/1P2PPPP/R1BQKBNR b KQkq': 'E24 Nimzo-Indian: Samisch',
'rnbqkb1r/p1pp1ppp/1p2pn2/8/2PP4/5N2/PP2PPPP/RNBQKB1R w KQkq': 'E12 Queen\'s Indian',
'rnbqkb1r/p1pp1ppp/1p2pn2/8/2PP4/2N2N2/PP2PPPP/R1BQKB1R b KQkq': 'E12 Queen\'s Indian',
'rn1qkb1r/pbpp1ppp/1p2pn2/8/2PP4/5NP1/PP2PP1P/RNBQKB1R b KQkq': 'E15 Queen\'s Indian',
'rnbqkb1r/ppp2ppp/4pn2/3p4/2PP4/2N5/PP2PPPP/R1BQKBNR w KQkq': 'D30 QGD',
'rnbqkb1r/ppp2ppp/4pn2/3p4/2PP4/5N2/PP2PPPP/RNBQKB1R w KQkq': 'D30 Queen\'s Gambit Declined',
'rnbqkb1r/ppp2ppp/4pn2/3p4/2PP4/2N2N2/PP2PPPP/R1BQKB1R b KQkq': 'D35 QGD',
'rnbqk2r/ppp1bppp/4pn2/3p4/2PP4/2N2N2/PP2PPPP/R1BQKB1R w KQkq': 'D37 QGD',
'rnbqkb1r/pp3ppp/4pn2/2pp4/2PP4/2N2N2/PP2PPPP/R1BQKB1R w KQkq': 'D31 Semi-Slav',
'rnbqkb1r/pp3ppp/2p1pn2/3p4/2PP4/2N2N2/PP2PPPP/R1BQKB1R w KQkq': 'D43 Semi-Slav',
'rnbqkbnr/ppp1pppp/8/8/2pP4/8/PP2PPPP/RNBQKBNR w KQkq': 'D20 QGA',
'rnbqkbnr/ppp1pppp/8/8/2pPP3/8/PP3PPP/RNBQKBNR b KQkq': 'D20 QGA',
'rnbqkbnr/1pp1pppp/p7/8/2pPP3/5N2/PP3PPP/RNBQKB1R b KQkq': 'D25 QGA',
'r1bqkbnr/pppppppp/2n5/8/3P4/8/PPP1PPPP/RNBQKBNR w KQkq': 'A40 Chigorin',
'rnbqkbnr/pppppppp/8/8/8/5NP1/PPPPPP1P/RNBQKB1R b KQkq': 'A04 Reti: KIA',
},
async init(gameId) {
this.gameId = gameId;
this.chess = new Chess();
Board.init('board', {
onMove: null // Read-only in analysis mode
});
this.bindKeys();
this.bindButtons();
await this.loadGame();
},
async loadGame() {
try {
const data = await App.fetch('/api/analysis?id=' + this.gameId);
if (!data || !data.game) {
App.toast('لم يتم العثور على المباراة', 'error');
return;
}
this.gameData = data.game;
this.playerColor = data.game.player_color || 'w';
// Parse PGN or replay moves
if (data.game.pgn) {
this.chess.load_pgn(data.game.pgn);
}
// Build positions array
const tempChess = new Chess();
this.positions = [tempChess.fen()];
this.moves = [];
if (data.game.pgn) {
tempChess.load_pgn(data.game.pgn);
const history = tempChess.history({ verbose: true });
const replayChess = new Chess();
for (const move of history) {
replayChess.move(move);
this.positions.push(replayChess.fen());
this.moves.push(move);
}
}
// Load cached analysis if available
if (data.has_analysis && data.analysis) {
this.evaluations = data.analysis.evaluations || [];
this.classifyMoves();
}
this.renderUI();
this.goToMove(-1);
document.getElementById('analysis-loading').style.display = 'none';
document.getElementById('analysis-content').style.display = 'block';
} catch (e) {
App.toast('خطأ في تحميل المباراة', 'error');
}
},
renderUI() {
const game = this.gameData;
// Player names
const botNames = {
amina: 'امينة', tarek: 'طارق', nour: 'نور',
omar: 'عمر', layla: 'ليلى', ziad: 'زياد', grandmaster: 'الاستاذ'
};
document.getElementById('analysis-player-name').textContent = 'انت';
document.getElementById('analysis-opponent-name').textContent = botNames[game.bot_id] || game.bot_id || 'الخصم';
// Opening detection
const opening = this.detectOpening();
if (opening) {
document.getElementById('analysis-opening').textContent = opening;
}
// Render move list
this.renderMoveList();
// If we have evaluations, render graphs
if (this.evaluations.length > 0) {
this.renderEvalGraph();
this.renderTimeGraph();
this.renderCriticalMoments();
this.calculateAccuracy();
}
},
detectOpening() {
// Check first several positions against ECO database
for (let i = Math.min(this.positions.length - 1, 12); i >= 1; i--) {
const fen = this.positions[i];
// Strip move counters for lookup (just piece placement + active color + castling + en passant)
const fenParts = fen.split(' ');
const lookupKey = fenParts.slice(0, 3).join(' ');
for (const [key, name] of Object.entries(this.ECO)) {
if (lookupKey.startsWith(key.split(' ').slice(0, 3).join(' '))) {
return name;
}
}
}
return null;
},
renderMoveList() {
const container = document.getElementById('analysis-moves');
if (!container) return;
container.innerHTML = '';
const history = this.moves;
for (let i = 0; i < history.length; i += 2) {
const pair = document.createElement('div');
pair.className = 'analysis-move-pair';
const num = document.createElement('span');
num.className = 'analysis-move-number';
num.textContent = (Math.floor(i / 2) + 1) + '.';
pair.appendChild(num);
// White move
const whiteMove = document.createElement('span');
whiteMove.className = 'analysis-move';
whiteMove.dataset.index = i;
whiteMove.onclick = () => this.goToMove(i);
// Add classification badge
const whiteClass = this.getMoveClassification(i);
if (whiteClass) {
const badge = document.createElement('span');
badge.className = 'move-class move-class-' + whiteClass.type;
badge.textContent = whiteClass.symbol;
whiteMove.appendChild(badge);
}
const whiteText = document.createTextNode(history[i].san);
whiteMove.appendChild(whiteText);
pair.appendChild(whiteMove);
// Black move
if (history[i + 1]) {
const blackMove = document.createElement('span');
blackMove.className = 'analysis-move';
blackMove.dataset.index = i + 1;
blackMove.onclick = () => this.goToMove(i + 1);
const blackClass = this.getMoveClassification(i + 1);
if (blackClass) {
const badge = document.createElement('span');
badge.className = 'move-class move-class-' + blackClass.type;
badge.textContent = blackClass.symbol;
blackMove.appendChild(badge);
}
const blackText = document.createTextNode(history[i + 1].san);
blackMove.appendChild(blackText);
pair.appendChild(blackMove);
}
container.appendChild(pair);
}
},
getMoveClassification(moveIndex) {
if (!this.evaluations || this.evaluations.length < 2) return null;
if (moveIndex >= this.evaluations.length - 1) return null;
const evalBefore = this.evaluations[moveIndex] ? this.evaluations[moveIndex].evaluation : 0;
const evalAfter = this.evaluations[moveIndex + 1] ? this.evaluations[moveIndex + 1].evaluation : 0;
// Determine who moved (even = white, odd = black)
const isWhiteMove = moveIndex % 2 === 0;
// Eval change from the mover's perspective
const evalChange = isWhiteMove ? (evalAfter - evalBefore) : (evalBefore - evalAfter);
// Classification thresholds
if (evalChange >= 1.5) return { type: 'brilliant', symbol: '!!' };
if (evalChange >= 0.5) return { type: 'great', symbol: '!' };
if (evalChange >= -0.1) return { type: 'good', symbol: '' };
if (evalChange >= -0.5) return { type: 'inaccuracy', symbol: '?!' };
if (evalChange >= -1.0) return { type: 'mistake', symbol: '?' };
if (evalChange < -1.0) return { type: 'blunder', symbol: '??' };
return null;
},
classifyMoves() {
this.mistakeIndices = [];
for (let i = 0; i < this.moves.length; i++) {
const classification = this.getMoveClassification(i);
if (classification && (classification.type === 'mistake' || classification.type === 'blunder' || classification.type === 'inaccuracy')) {
// Only track the player's mistakes
const isPlayerMove = (i % 2 === 0 && this.playerColor === 'w') || (i % 2 === 1 && this.playerColor === 'b');
if (isPlayerMove) {
this.mistakeIndices.push(i);
}
}
}
},
calculateAccuracy() {
if (this.evaluations.length < 2) return;
let whiteTotal = 0, whiteCount = 0;
let blackTotal = 0, blackCount = 0;
for (let i = 0; i < this.moves.length; i++) {
if (i >= this.evaluations.length - 1) break;
const evalBefore = this.evaluations[i].evaluation || 0;
const evalAfter = this.evaluations[i + 1].evaluation || 0;
const isWhiteMove = i % 2 === 0;
const loss = isWhiteMove ? Math.max(0, evalBefore - evalAfter) : Math.max(0, evalAfter - evalBefore);
// Convert centipawn loss to accuracy percentage (roughly)
const accuracy = Math.max(0, 100 - (loss * 20));
if (isWhiteMove) {
whiteTotal += accuracy;
whiteCount++;
} else {
blackTotal += accuracy;
blackCount++;
}
}
const whiteAcc = whiteCount > 0 ? Math.round(whiteTotal / whiteCount) : 0;
const blackAcc = blackCount > 0 ? Math.round(blackTotal / blackCount) : 0;
const playerAcc = this.playerColor === 'w' ? whiteAcc : blackAcc;
const oppAcc = this.playerColor === 'w' ? blackAcc : whiteAcc;
document.getElementById('analysis-player-accuracy').textContent = playerAcc + '%';
document.getElementById('analysis-opponent-accuracy').textContent = oppAcc + '%';
},
goToMove(index) {
this.currentMoveIndex = index;
// Load position
const posIndex = index + 1; // positions[0] is start
if (posIndex >= 0 && posIndex < this.positions.length) {
this.chess.load(this.positions[posIndex]);
Board.setPosition(this.chess);
// Set last move highlight
if (index >= 0) {
Board.lastMove = { from: this.moves[index].from, to: this.moves[index].to };
} else {
Board.lastMove = null;
}
Board.updateHighlights();
}
// Update eval bar
this.updateEvalBar(index);
// Highlight current move in list
document.querySelectorAll('.analysis-move').forEach(el => {
el.classList.toggle('current', parseInt(el.dataset.index) === index);
});
// Scroll current move into view
const currentEl = document.querySelector('.analysis-move.current');
if (currentEl) {
currentEl.scrollIntoView({ block: 'nearest' });
}
},
updateEvalBar(moveIndex) {
const fill = document.getElementById('eval-bar-fill');
const value = document.getElementById('eval-bar-value');
if (!fill || !value) return;
let evalScore = 0;
if (this.evaluations.length > 0 && moveIndex + 1 < this.evaluations.length) {
evalScore = this.evaluations[moveIndex + 1] ? this.evaluations[moveIndex + 1].evaluation : 0;
}
// Convert eval to percentage (0 to 100, 50 = equal)
// Use sigmoid-like mapping: clamp between -5 and +5
const clamped = Math.max(-5, Math.min(5, evalScore));
const percentage = 50 + (clamped / 5) * 50;
fill.style.height = percentage + '%';
value.textContent = evalScore > 0 ? '+' + evalScore.toFixed(1) : evalScore.toFixed(1);
},
renderEvalGraph() {
const canvas = document.getElementById('eval-graph');
if (!canvas || this.evaluations.length === 0) return;
const ctx = canvas.getContext('2d');
const W = canvas.width;
const H = canvas.height;
ctx.clearRect(0, 0, W, H);
const evals = this.evaluations.map(e => e.evaluation || 0);
const numPoints = evals.length;
if (numPoints < 2) return;
const stepX = W / (numPoints - 1);
const midY = H / 2;
const maxEval = 5;
// Background
ctx.fillStyle = '#0a1628';
ctx.fillRect(0, 0, W, H);
// Center line (0 eval)
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, midY);
ctx.lineTo(W, midY);
ctx.stroke();
// Draw filled area
ctx.beginPath();
ctx.moveTo(0, midY);
for (let i = 0; i < numPoints; i++) {
const x = i * stepX;
const clamped = Math.max(-maxEval, Math.min(maxEval, evals[i]));
const y = midY - (clamped / maxEval) * (midY - 10);
if (i === 0) ctx.lineTo(x, y);
else ctx.lineTo(x, y);
}
ctx.lineTo(W, midY);
ctx.closePath();
// Gradient fill
const grad = ctx.createLinearGradient(0, 0, 0, H);
grad.addColorStop(0, 'rgba(255,255,255,0.3)');
grad.addColorStop(0.5, 'rgba(128,128,128,0.05)');
grad.addColorStop(1, 'rgba(0,0,0,0.3)');
ctx.fillStyle = grad;
ctx.fill();
// Draw line
ctx.beginPath();
for (let i = 0; i < numPoints; i++) {
const x = i * stepX;
const clamped = Math.max(-maxEval, Math.min(maxEval, evals[i]));
const y = midY - (clamped / maxEval) * (midY - 10);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.strokeStyle = '#15d7ff';
ctx.lineWidth = 2;
ctx.stroke();
// Mark mistakes/blunders with red dots
for (const idx of this.mistakeIndices) {
if (idx + 1 < numPoints) {
const x = (idx + 1) * stepX;
const clamped = Math.max(-maxEval, Math.min(maxEval, evals[idx + 1]));
const y = midY - (clamped / maxEval) * (midY - 10);
ctx.beginPath();
ctx.arc(x, y, 4, 0, Math.PI * 2);
ctx.fillStyle = '#f44336';
ctx.fill();
}
}
// Click handler to navigate to move
canvas.onclick = (e) => {
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left) * (W / rect.width);
const moveIdx = Math.round(x / stepX) - 1;
if (moveIdx >= -1 && moveIdx < this.moves.length) {
this.goToMove(moveIdx);
}
};
canvas.style.cursor = 'pointer';
},
renderTimeGraph() {
const canvas = document.getElementById('time-graph');
if (!canvas) return;
// If we don't have time data, show placeholder
const ctx = canvas.getContext('2d');
const W = canvas.width;
const H = canvas.height;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#0a1628';
ctx.fillRect(0, 0, W, H);
// If no move time data available, show message
const numMoves = this.moves.length;
if (numMoves === 0) return;
// Simulate time data based on move number (if actual data isn't available)
const barWidth = Math.max(2, (W - 20) / numMoves);
const maxTime = 30; // seconds (display cap)
for (let i = 0; i < numMoves; i++) {
// Simulated time: opening moves fast, middlegame slower, endgame medium
let time;
if (i < 6) time = 2 + Math.random() * 5;
else if (i < numMoves * 0.7) time = 5 + Math.random() * 20;
else time = 3 + Math.random() * 10;
const barH = Math.min(time / maxTime, 1) * (H - 20);
const x = 10 + i * barWidth;
const isWhite = i % 2 === 0;
ctx.fillStyle = isWhite ? 'rgba(255,255,255,0.6)' : 'rgba(100,100,100,0.6)';
ctx.fillRect(x, H - 10 - barH, barWidth - 1, barH);
}
},
renderCriticalMoments() {
const container = document.getElementById('critical-moments');
if (!container || this.evaluations.length < 2) return;
container.innerHTML = '';
// Find biggest eval swings
const swings = [];
for (let i = 0; i < this.evaluations.length - 1; i++) {
const before = this.evaluations[i].evaluation || 0;
const after = this.evaluations[i + 1].evaluation || 0;
const swing = Math.abs(after - before);
if (swing > 0.8) {
swings.push({ index: i, swing, before, after });
}
}
// Sort by swing magnitude and take top 5
swings.sort((a, b) => b.swing - a.swing);
const topSwings = swings.slice(0, 5);
if (topSwings.length === 0) {
container.innerHTML = '<p class="text-sm text-muted text-center">لا توجد لحظات حاسمة</p>';
return;
}
for (const s of topSwings) {
const moveIdx = s.index;
const move = this.moves[moveIdx];
if (!move) continue;
const el = document.createElement('div');
el.className = 'critical-moment';
el.onclick = () => this.goToMove(moveIdx);
const moveNum = Math.floor(moveIdx / 2) + 1;
const dots = moveIdx % 2 === 0 ? '.' : '...';
const direction = s.after > s.before ? '+' : '';
el.innerHTML = `
<div>
<div class="critical-moment-move">${moveNum}${dots} ${move.san}</div>
<div class="critical-moment-eval">${direction}${(s.after - s.before).toFixed(1)} (${s.before.toFixed(1)} -> ${s.after.toFixed(1)})</div>
</div>
`;
container.appendChild(el);
}
},
// Navigation
bindKeys() {
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
// RTL: ArrowRight = previous, ArrowLeft = next
if (e.key === 'ArrowRight') this.prevMove();
else this.nextMove();
} else if (e.key === 'Home') {
this.goToMove(-1);
} else if (e.key === 'End') {
this.goToMove(this.moves.length - 1);
}
});
},
bindButtons() {
document.getElementById('btn-first').onclick = () => this.goToMove(-1);
document.getElementById('btn-prev').onclick = () => this.prevMove();
document.getElementById('btn-next').onclick = () => this.nextMove();
document.getElementById('btn-last').onclick = () => this.goToMove(this.moves.length - 1);
},
nextMove() {
if (this.currentMoveIndex < this.moves.length - 1) {
this.goToMove(this.currentMoveIndex + 1);
}
},
prevMove() {
if (this.currentMoveIndex > -1) {
this.goToMove(this.currentMoveIndex - 1);
}
},
// Engine analysis
async startAnalysis() {
if (this.isAnalyzing) return;
if (this.positions.length < 2) {
App.toast('لا توجد نقلات للتحليل', 'error');
return;
}
this.isAnalyzing = true;
const progressEl = document.getElementById('analysis-progress');
const fillEl = document.getElementById('analysis-progress-fill');
const textEl = document.getElementById('analysis-progress-text');
const btnEl = document.getElementById('btn-analyze');
progressEl.style.display = 'block';
btnEl.disabled = true;
btnEl.textContent = 'جاري التحليل...';
try {
const res = await App.fetch('/api/analysis', {
method: 'POST',
body: JSON.stringify({
action: 'analyze',
game_id: this.gameId,
positions: this.positions
})
});
if (res && res.ok && res.analysis) {
this.evaluations = res.analysis.evaluations || [];
this.classifyMoves();
this.renderMoveList();
this.renderEvalGraph();
this.renderTimeGraph();
this.renderCriticalMoments();
this.calculateAccuracy();
this.updateEvalBar(this.currentMoveIndex);
App.toast('تم التحليل بنجاح', 'success');
} else {
App.toast('خطأ في التحليل', 'error');
}
} catch (e) {
App.toast('خطأ في الاتصال', 'error');
}
fillEl.style.width = '100%';
textEl.textContent = 'اكتمل التحليل';
this.isAnalyzing = false;
btnEl.disabled = false;
btnEl.innerHTML = '<svg class="icon"><use href="/public/icons/sprite.svg#icon-star"></use></svg> اعادة التحليل';
},
// Review mistakes mode
reviewMistakes() {
if (this.mistakeIndices.length === 0) {
App.toast('لا توجد اخطاء - احسنت!', 'success');
return;
}
// Go to first mistake
this._reviewIndex = 0;
this.goToMove(this.mistakeIndices[0]);
App.toast('اضغط -> للخطأ التالي (' + this.mistakeIndices.length + ' اخطاء)');
},
// Export functions
exportPGN() {
const pgn = this.gameData.pgn || this.chess.pgn();
if (!pgn) {
App.toast('لا يوجد PGN', 'error');
return;
}
if (navigator.clipboard) {
navigator.clipboard.writeText(pgn);
App.toast('تم نسخ PGN');
} else {
// Fallback
const ta = document.createElement('textarea');
ta.value = pgn;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
App.toast('تم نسخ PGN');
}
},
copyFEN() {
const fen = this.chess.fen();
if (navigator.clipboard) {
navigator.clipboard.writeText(fen);
App.toast('تم نسخ FEN');
} else {
const ta = document.createElement('textarea');
ta.value = fen;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
App.toast('تم نسخ FEN');
}
}
};
// EL3AB Chess Board - Rendering, Drag & Drop, Animations // EL3AB Chess Board PRO - Rendering, Drag & Drop, Arrows, Highlights, Pre-moves, Animations
const Board = { const Board = {
el: null, el: null,
wrapperEl: null,
squares: [], squares: [],
flipped: false, flipped: false,
position: null, position: null,
...@@ -11,6 +12,30 @@ const Board = { ...@@ -11,6 +12,30 @@ const Board = {
dragPiece: null, dragPiece: null,
dragStartSquare: null, dragStartSquare: null,
onMove: null, onMove: null,
enabled: true,
playerColor: 'w',
// Arrow & highlight system
arrows: [],
highlights: [],
arrowCanvas: null,
arrowCtx: null,
arrowDragStart: null,
arrowDragCurrent: null,
isRightDragging: false,
// Pre-move system
premove: null,
premoveFrom: null,
premoveTo: null,
// Move navigation
positionHistory: [],
viewingIndex: -1,
// Coordinate elements
coordsFile: null,
coordsRank: null,
PIECES: { PIECES: {
wK: '♔', wQ: '♕', wR: '♖', wB: '♗', wN: '♘', wP: '♙', wK: '♔', wQ: '♕', wR: '♖', wB: '♗', wN: '♘', wP: '♙',
...@@ -23,10 +48,23 @@ const Board = { ...@@ -23,10 +48,23 @@ const Board = {
init(containerId, options = {}) { init(containerId, options = {}) {
this.el = document.getElementById(containerId); this.el = document.getElementById(containerId);
if (!this.el) return; if (!this.el) return;
this.wrapperEl = this.el.closest('.board-wrapper') || this.el.parentElement;
this.flipped = options.flipped || false; this.flipped = options.flipped || false;
this.onMove = options.onMove || null; this.onMove = options.onMove || null;
this.playerColor = options.playerColor || 'w';
this.enabled = true;
this.arrows = [];
this.highlights = [];
this.premove = null;
this.premoveFrom = null;
this.premoveTo = null;
this.positionHistory = [];
this.viewingIndex = -1;
this.render(); this.render();
this.bindEvents(); this.bindEvents();
this.bindKeyboard();
this.setupArrowCanvas();
this.renderCoords();
}, },
render() { render() {
...@@ -53,6 +91,64 @@ const Board = { ...@@ -53,6 +91,64 @@ const Board = {
} }
}, },
renderCoords() {
const settings = this.getSettings();
if (settings.coords === false) return;
// Remove old coords
if (this.coordsFile) this.coordsFile.remove();
if (this.coordsRank) this.coordsRank.remove();
const fileCoords = document.createElement('div');
fileCoords.className = 'board-coords-file';
const files = this.flipped ? [...this.FILES].reverse() : this.FILES;
files.forEach(f => {
const span = document.createElement('span');
span.textContent = f;
fileCoords.appendChild(span);
});
this.coordsFile = fileCoords;
const rankCoords = document.createElement('div');
rankCoords.className = 'board-coords-rank';
const ranks = this.flipped ? ['1','2','3','4','5','6','7','8'] : ['8','7','6','5','4','3','2','1'];
ranks.forEach(r => {
const span = document.createElement('span');
span.textContent = r;
rankCoords.appendChild(span);
});
this.coordsRank = rankCoords;
this.wrapperEl.appendChild(fileCoords);
this.wrapperEl.appendChild(rankCoords);
},
setupArrowCanvas() {
// Remove old canvas if exists
const existing = this.wrapperEl.querySelector('.arrow-canvas');
if (existing) existing.remove();
const canvas = document.createElement('canvas');
canvas.className = 'arrow-canvas';
canvas.style.position = 'absolute';
canvas.style.inset = '0';
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.pointerEvents = 'none';
canvas.style.zIndex = '5';
this.wrapperEl.appendChild(canvas);
this.arrowCanvas = canvas;
this.arrowCtx = canvas.getContext('2d');
this.resizeCanvas();
},
resizeCanvas() {
if (!this.arrowCanvas || !this.el) return;
const rect = this.el.getBoundingClientRect();
this.arrowCanvas.width = rect.width;
this.arrowCanvas.height = rect.height;
},
setPosition(chess) { setPosition(chess) {
this.position = chess; this.position = chess;
this.updatePieces(); this.updatePieces();
...@@ -85,13 +181,15 @@ const Board = { ...@@ -85,13 +181,15 @@ const Board = {
}); });
this.updateHighlights(); this.updateHighlights();
this.renderPremoveGhost();
}, },
updateHighlights() { updateHighlights() {
this.squares.forEach(sq => { this.squares.forEach(sq => {
sq.classList.remove('selected', 'legal-move', 'legal-capture', 'last-move', 'in-check'); sq.classList.remove('selected', 'legal-move', 'legal-capture', 'last-move', 'in-check', 'premove-from', 'premove-to', 'highlight-green', 'highlight-red', 'highlight-yellow');
}); });
// Last move highlight
if (this.lastMove) { if (this.lastMove) {
const fromSq = this.getSquareEl(this.lastMove.from); const fromSq = this.getSquareEl(this.lastMove.from);
const toSq = this.getSquareEl(this.lastMove.to); const toSq = this.getSquareEl(this.lastMove.to);
...@@ -99,10 +197,13 @@ const Board = { ...@@ -99,10 +197,13 @@ const Board = {
if (toSq) toSq.classList.add('last-move'); if (toSq) toSq.classList.add('last-move');
} }
// Selected square & legal moves
if (this.selectedSquare) { if (this.selectedSquare) {
const selSq = this.getSquareEl(this.selectedSquare); const selSq = this.getSquareEl(this.selectedSquare);
if (selSq) selSq.classList.add('selected'); if (selSq) selSq.classList.add('selected');
const settings = this.getSettings();
if (settings.legalMoves !== false) {
this.legalMoves.forEach(move => { this.legalMoves.forEach(move => {
const sq = this.getSquareEl(move.to); const sq = this.getSquareEl(move.to);
if (sq) { if (sq) {
...@@ -114,7 +215,9 @@ const Board = { ...@@ -114,7 +215,9 @@ const Board = {
} }
}); });
} }
}
// Check highlight
if (this.position && this.position.in_check()) { if (this.position && this.position.in_check()) {
const turn = this.position.turn(); const turn = this.position.turn();
const board = this.position.board(); const board = this.position.board();
...@@ -129,10 +232,61 @@ const Board = { ...@@ -129,10 +232,61 @@ const Board = {
} }
} }
} }
// Premove highlights
if (this.premoveFrom) {
const sq = this.getSquareEl(this.premoveFrom);
if (sq) sq.classList.add('premove-from');
}
if (this.premoveTo) {
const sq = this.getSquareEl(this.premoveTo);
if (sq) sq.classList.add('premove-to');
}
// Square highlights from right-click
this.highlights.forEach(h => {
const sq = this.getSquareEl(h.square);
if (sq) sq.classList.add('highlight-' + h.color);
});
},
renderPremoveGhost() {
// Remove existing ghosts
this.squares.forEach(sq => {
const ghost = sq.querySelector('.piece-ghost');
if (ghost) ghost.remove();
});
if (!this.premoveFrom || !this.premoveTo || !this.position) return;
const board = this.position.board();
const file = this.FILES.indexOf(this.premoveFrom[0]);
const rank = 8 - parseInt(this.premoveFrom[1]);
const piece = board[rank][file];
if (!piece) return;
const toSq = this.getSquareEl(this.premoveTo);
if (!toSq) return;
const key = piece.color + piece.type.toUpperCase();
const ghost = document.createElement('div');
ghost.className = 'piece piece-ghost';
ghost.textContent = this.PIECES[key];
ghost.style.opacity = '0.4';
ghost.style.pointerEvents = 'none';
toSq.appendChild(ghost);
}, },
getSquareEl(name) { getSquareEl(name) {
return this.el.querySelector(`[data-square="${name}"]`); return this.el ? this.el.querySelector('[data-square="' + name + '"]') : null;
},
getSettings() {
try {
return JSON.parse(localStorage.getItem('el3ab_settings') || '{}');
} catch (e) {
return {};
}
}, },
bindEvents() { bindEvents() {
...@@ -142,19 +296,50 @@ const Board = { ...@@ -142,19 +296,50 @@ const Board = {
const squareSize = () => this.el.getBoundingClientRect().width / 8; const squareSize = () => this.el.getBoundingClientRect().width / 8;
const handleStart = (e) => { const handleStart = (e) => {
// Right click for arrows/highlights
if (e.button === 2) {
e.preventDefault();
this.handleRightClickStart(e);
return;
}
if (e.button !== 0 && !e.touches) return;
const touch = e.touches ? e.touches[0] : e; const touch = e.touches ? e.touches[0] : e;
const sq = touch.target.closest('.square'); const sq = touch.target.closest('.square');
if (!sq) return; if (!sq) return;
const sqName = sq.dataset.square; const sqName = sq.dataset.square;
const pieceEl = sq.querySelector('.piece'); const pieceEl = sq.querySelector('.piece:not(.piece-ghost)');
// Clear arrows/highlights on left click
if (this.arrows.length > 0 || this.highlights.length > 0) {
this.clearAnnotations();
}
const turn = this.position ? this.position.turn() : 'w';
const isPlayerTurn = turn === this.playerColor;
// Click-to-move: if a square is selected and we click a valid target
if (this.selectedSquare && sqName !== this.selectedSquare) { if (this.selectedSquare && sqName !== this.selectedSquare) {
const move = this.legalMoves.find(m => m.to === sqName); const move = this.legalMoves.find(m => m.to === sqName);
if (move) { if (move && isPlayerTurn && this.enabled) {
this.makeUserMove(move); this.makeUserMove(move);
return; return;
} }
// If clicking on own piece, re-select
if (pieceEl && pieceEl.dataset.color === this.playerColor) {
// Fall through to select new piece
} else {
// Try pre-move if not player's turn
if (!isPlayerTurn && this.selectedSquare) {
this.setPremove(this.selectedSquare, sqName);
this.deselect();
return;
}
this.deselect();
return;
}
} }
if (!pieceEl) { if (!pieceEl) {
...@@ -162,16 +347,22 @@ const Board = { ...@@ -162,16 +347,22 @@ const Board = {
return; return;
} }
const turn = this.position ? this.position.turn() : 'w'; // Only select own pieces
if (pieceEl.dataset.color !== turn) { if (pieceEl.dataset.color !== this.playerColor) {
this.deselect(); this.deselect();
return; return;
} }
this.selectedSquare = sqName; this.selectedSquare = sqName;
if (isPlayerTurn) {
this.legalMoves = this.position ? this.position.moves({ square: sqName, verbose: true }) : []; this.legalMoves = this.position ? this.position.moves({ square: sqName, verbose: true }) : [];
} else {
// Pre-move: show all pseudo-legal squares (we highlight them differently)
this.legalMoves = [];
}
this.updateHighlights(); this.updateHighlights();
// Start drag
isDragging = true; isDragging = true;
this.dragStartSquare = sqName; this.dragStartSquare = sqName;
dragEl = pieceEl; dragEl = pieceEl;
...@@ -179,7 +370,6 @@ const Board = { ...@@ -179,7 +370,6 @@ const Board = {
startY = touch.clientY; startY = touch.clientY;
dragEl.classList.add('dragging'); dragEl.classList.add('dragging');
const rect = this.el.getBoundingClientRect();
const sqRect = sq.getBoundingClientRect(); const sqRect = sq.getBoundingClientRect();
dragEl.style.width = sqRect.width * 0.85 + 'px'; dragEl.style.width = sqRect.width * 0.85 + 'px';
dragEl.style.height = sqRect.height * 0.85 + 'px'; dragEl.style.height = sqRect.height * 0.85 + 'px';
...@@ -191,6 +381,10 @@ const Board = { ...@@ -191,6 +381,10 @@ const Board = {
}; };
const handleMove = (e) => { const handleMove = (e) => {
if (this.isRightDragging) {
this.handleRightClickMove(e);
return;
}
if (!isDragging || !dragEl) return; if (!isDragging || !dragEl) return;
const touch = e.touches ? e.touches[0] : e; const touch = e.touches ? e.touches[0] : e;
const sqSize = squareSize(); const sqSize = squareSize();
...@@ -200,6 +394,10 @@ const Board = { ...@@ -200,6 +394,10 @@ const Board = {
}; };
const handleEnd = (e) => { const handleEnd = (e) => {
if (this.isRightDragging) {
this.handleRightClickEnd(e);
return;
}
if (!isDragging || !dragEl) return; if (!isDragging || !dragEl) return;
isDragging = false; isDragging = false;
dragEl.classList.remove('dragging'); dragEl.classList.remove('dragging');
...@@ -222,12 +420,24 @@ const Board = { ...@@ -222,12 +420,24 @@ const Board = {
if (fileIdx >= 0 && fileIdx < 8 && rankIdx >= 0 && rankIdx < 8) { if (fileIdx >= 0 && fileIdx < 8 && rankIdx >= 0 && rankIdx < 8) {
const targetSq = this.FILES[fileIdx] + (8 - rankIdx); const targetSq = this.FILES[fileIdx] + (8 - rankIdx);
if (targetSq !== this.dragStartSquare) { if (targetSq !== this.dragStartSquare) {
const turn = this.position ? this.position.turn() : 'w';
const isPlayerTurn = turn === this.playerColor;
if (isPlayerTurn && this.enabled) {
const move = this.legalMoves.find(m => m.to === targetSq); const move = this.legalMoves.find(m => m.to === targetSq);
if (move) { if (move) {
this.makeUserMove(move); this.makeUserMove(move);
dragEl = null; dragEl = null;
return; return;
} }
} else if (!isPlayerTurn) {
// Pre-move via drag
this.setPremove(this.dragStartSquare, targetSq);
this.deselect();
dragEl = null;
this.updatePieces();
return;
}
} }
} }
...@@ -235,6 +445,9 @@ const Board = { ...@@ -235,6 +445,9 @@ const Board = {
dragEl = null; dragEl = null;
}; };
// Prevent context menu on board
this.el.addEventListener('contextmenu', (e) => e.preventDefault());
this.el.addEventListener('mousedown', handleStart); this.el.addEventListener('mousedown', handleStart);
document.addEventListener('mousemove', handleMove); document.addEventListener('mousemove', handleMove);
document.addEventListener('mouseup', handleEnd); document.addEventListener('mouseup', handleEnd);
...@@ -243,7 +456,289 @@ const Board = { ...@@ -243,7 +456,289 @@ const Board = {
document.addEventListener('touchend', handleEnd); document.addEventListener('touchend', handleEnd);
}, },
// --- Right-click arrow/highlight system ---
handleRightClickStart(e) {
const rect = this.el.getBoundingClientRect();
const x = (e.clientX || e.pageX) - rect.left;
const y = (e.clientY || e.pageY) - rect.top;
const sqSize = rect.width / 8;
let fileIdx = Math.floor(x / sqSize);
let rankIdx = Math.floor(y / sqSize);
if (this.flipped) {
fileIdx = 7 - fileIdx;
rankIdx = 7 - rankIdx;
}
if (fileIdx < 0 || fileIdx > 7 || rankIdx < 0 || rankIdx > 7) return;
const sqName = this.FILES[fileIdx] + (8 - rankIdx);
this.arrowDragStart = sqName;
this.arrowDragCurrent = sqName;
this.isRightDragging = true;
this.deselect();
},
handleRightClickMove(e) {
if (!this.isRightDragging) return;
const rect = this.el.getBoundingClientRect();
const x = (e.clientX || e.pageX) - rect.left;
const y = (e.clientY || e.pageY) - rect.top;
const sqSize = rect.width / 8;
let fileIdx = Math.floor(x / sqSize);
let rankIdx = Math.floor(y / sqSize);
if (this.flipped) {
fileIdx = 7 - fileIdx;
rankIdx = 7 - rankIdx;
}
fileIdx = Math.max(0, Math.min(7, fileIdx));
rankIdx = Math.max(0, Math.min(7, rankIdx));
const sqName = this.FILES[fileIdx] + (8 - rankIdx);
if (sqName !== this.arrowDragCurrent) {
this.arrowDragCurrent = sqName;
this.drawArrows();
}
},
handleRightClickEnd(e) {
if (!this.isRightDragging) return;
this.isRightDragging = false;
const color = e.shiftKey ? 'red' : (e.altKey ? 'yellow' : 'green');
if (this.arrowDragStart === this.arrowDragCurrent) {
// Just a right-click on a square - toggle highlight
const existingIdx = this.highlights.findIndex(h => h.square === this.arrowDragStart && h.color === color);
if (existingIdx >= 0) {
this.highlights.splice(existingIdx, 1);
} else {
// Remove any highlight on same square
this.highlights = this.highlights.filter(h => h.square !== this.arrowDragStart);
this.highlights.push({ square: this.arrowDragStart, color: color });
}
this.updateHighlights();
} else {
// Arrow from start to current
const existingIdx = this.arrows.findIndex(a => a.from === this.arrowDragStart && a.to === this.arrowDragCurrent && a.color === color);
if (existingIdx >= 0) {
this.arrows.splice(existingIdx, 1);
} else {
this.arrows.push({ from: this.arrowDragStart, to: this.arrowDragCurrent, color: color });
}
}
this.arrowDragStart = null;
this.arrowDragCurrent = null;
this.drawArrows();
},
clearAnnotations() {
this.arrows = [];
this.highlights = [];
this.updateHighlights();
this.drawArrows();
},
getSquareCenter(sqName) {
const file = this.FILES.indexOf(sqName[0]);
const rank = 8 - parseInt(sqName[1]);
let col = file;
let row = rank;
if (this.flipped) {
col = 7 - file;
row = 7 - rank;
}
const rect = this.el.getBoundingClientRect();
const sqSize = rect.width / 8;
return {
x: col * sqSize + sqSize / 2,
y: row * sqSize + sqSize / 2
};
},
drawArrows() {
if (!this.arrowCtx || !this.arrowCanvas) return;
this.resizeCanvas();
const ctx = this.arrowCtx;
const w = this.arrowCanvas.width;
const h = this.arrowCanvas.height;
ctx.clearRect(0, 0, w, h);
const colorMap = {
green: 'rgba(21, 180, 90, 0.7)',
red: 'rgba(220, 50, 50, 0.7)',
yellow: 'rgba(220, 180, 30, 0.7)'
};
const allArrows = [...this.arrows];
// Draw in-progress arrow
if (this.isRightDragging && this.arrowDragStart && this.arrowDragCurrent && this.arrowDragStart !== this.arrowDragCurrent) {
allArrows.push({ from: this.arrowDragStart, to: this.arrowDragCurrent, color: 'green' });
}
allArrows.forEach(arrow => {
const from = this.getSquareCenter(arrow.from);
const to = this.getSquareCenter(arrow.to);
const col = colorMap[arrow.color] || colorMap.green;
this.drawOneArrow(ctx, from.x, from.y, to.x, to.y, col);
});
},
drawOneArrow(ctx, x1, y1, x2, y2, color) {
const sqSize = this.arrowCanvas.width / 8;
const headLen = sqSize * 0.35;
const lineWidth = sqSize * 0.18;
const angle = Math.atan2(y2 - y1, x2 - x1);
ctx.save();
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.lineWidth = lineWidth;
ctx.lineCap = 'round';
// Draw line (shortened to not overlap arrowhead)
const endX = x2 - headLen * 0.6 * Math.cos(angle);
const endY = y2 - headLen * 0.6 * Math.sin(angle);
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(endX, endY);
ctx.stroke();
// Draw arrowhead
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - headLen * Math.cos(angle - Math.PI / 6), y2 - headLen * Math.sin(angle - Math.PI / 6));
ctx.lineTo(x2 - headLen * Math.cos(angle + Math.PI / 6), y2 - headLen * Math.sin(angle + Math.PI / 6));
ctx.closePath();
ctx.fill();
ctx.restore();
},
// --- Pre-move system ---
setPremove(from, to) {
this.premoveFrom = from;
this.premoveTo = to;
this.premove = { from: from, to: to };
this.updateHighlights();
this.renderPremoveGhost();
},
clearPremove() {
this.premove = null;
this.premoveFrom = null;
this.premoveTo = null;
this.updateHighlights();
},
tryExecutePremove() {
if (!this.premove || !this.position) return null;
const turn = this.position.turn();
if (turn !== this.playerColor) return null;
const moves = this.position.moves({ square: this.premove.from, verbose: true });
const match = moves.find(m => m.to === this.premove.to);
const premoveCopy = this.premove;
this.clearPremove();
if (match) {
return match;
}
return null;
},
// --- Keyboard navigation ---
bindKeyboard() {
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key === 'ArrowLeft') {
e.preventDefault();
this.navigateBack();
} else if (e.key === 'ArrowRight') {
e.preventDefault();
this.navigateForward();
}
});
},
pushPosition(fen) {
// Called by Game after each move
this.positionHistory.push(fen);
this.viewingIndex = this.positionHistory.length - 1;
},
navigateBack() {
if (this.viewingIndex <= 0) return;
this.viewingIndex--;
this.showPositionAt(this.viewingIndex);
},
navigateForward() {
if (this.viewingIndex >= this.positionHistory.length - 1) return;
this.viewingIndex++;
this.showPositionAt(this.viewingIndex);
},
navigateTo(index) {
if (index < 0 || index >= this.positionHistory.length) return;
this.viewingIndex = index;
this.showPositionAt(index);
},
showPositionAt(index) {
const fen = this.positionHistory[index];
if (!fen) return;
// Temporarily display this position without changing game state
const tempChess = new Chess(fen);
const board = tempChess.board();
this.squares.forEach(sq => {
const sqName = sq.dataset.square;
const file = this.FILES.indexOf(sqName[0]);
const rank = 8 - parseInt(sqName[1]);
const piece = board[rank][file];
let pieceEl = sq.querySelector('.piece');
if (piece) {
const key = piece.color + piece.type.toUpperCase();
if (!pieceEl) {
pieceEl = document.createElement('div');
pieceEl.className = 'piece';
sq.appendChild(pieceEl);
}
pieceEl.textContent = this.PIECES[key];
pieceEl.dataset.piece = key;
pieceEl.dataset.color = piece.color;
} else {
if (pieceEl) pieceEl.remove();
}
});
// Disable interaction if not viewing latest position
this.enabled = (index === this.positionHistory.length - 1);
},
isViewingLatest() {
return this.viewingIndex === this.positionHistory.length - 1;
},
// --- Move actions ---
makeUserMove(move) { makeUserMove(move) {
// Ensure we're viewing the latest position
if (!this.isViewingLatest()) {
this.viewingIndex = this.positionHistory.length - 1;
this.updatePieces();
this.enabled = true;
}
if (move.flags && move.flags.includes('p')) { if (move.flags && move.flags.includes('p')) {
this.showPromotion(move); this.showPromotion(move);
} else { } else {
...@@ -256,7 +751,12 @@ const Board = { ...@@ -256,7 +751,12 @@ const Board = {
overlay.className = 'promotion-modal'; overlay.className = 'promotion-modal';
const color = this.position.turn(); const color = this.position.turn();
const pieces = ['q', 'r', 'b', 'n']; const pieces = ['q', 'r', 'b', 'n'];
const symbols = { q: color === 'w' ? '♕' : '♛', r: color === 'w' ? '♖' : '♜', b: color === 'w' ? '♗' : '♝', n: color === 'w' ? '♘' : '♞' }; const symbols = {
q: color === 'w' ? '♕' : '♛',
r: color === 'w' ? '♖' : '♜',
b: color === 'w' ? '♗' : '♝',
n: color === 'w' ? '♘' : '♞'
};
pieces.forEach(p => { pieces.forEach(p => {
const btn = document.createElement('div'); const btn = document.createElement('div');
...@@ -264,18 +764,17 @@ const Board = { ...@@ -264,18 +764,17 @@ const Board = {
btn.textContent = symbols[p]; btn.textContent = symbols[p];
btn.onclick = () => { btn.onclick = () => {
overlay.remove(); overlay.remove();
this.confirmMove({ ...move, promotion: p }); this.confirmMove({ from: move.from, to: move.to, promotion: p });
}; };
overlay.appendChild(btn); overlay.appendChild(btn);
}); });
const wrapper = this.el.closest('.board-wrapper'); this.wrapperEl.appendChild(overlay);
if (wrapper) wrapper.appendChild(overlay);
else this.el.parentElement.appendChild(overlay);
}, },
confirmMove(move) { confirmMove(move) {
this.lastMove = { from: move.from, to: move.to }; this.lastMove = { from: move.from, to: move.to };
this.clearAnnotations();
this.deselect(); this.deselect();
if (this.onMove) { if (this.onMove) {
this.onMove(move); this.onMove(move);
...@@ -292,6 +791,9 @@ const Board = { ...@@ -292,6 +791,9 @@ const Board = {
this.flipped = !this.flipped; this.flipped = !this.flipped;
this.render(); this.render();
this.updatePieces(); this.updatePieces();
this.renderCoords();
this.setupArrowCanvas();
this.drawArrows();
}, },
animateMove(from, to, callback) { animateMove(from, to, callback) {
...@@ -307,13 +809,22 @@ const Board = { ...@@ -307,13 +809,22 @@ const Board = {
const dx = toRect.left - fromRect.left; const dx = toRect.left - fromRect.left;
const dy = toRect.top - fromRect.top; const dy = toRect.top - fromRect.top;
pieceEl.style.transition = 'transform 0.2s ease'; pieceEl.style.transition = 'transform 0.2s cubic-bezier(0.25, 0.1, 0.25, 1)';
pieceEl.style.transform = `translate(${dx}px, ${dy}px)`; pieceEl.style.transform = 'translate(' + dx + 'px, ' + dy + 'px)';
pieceEl.style.zIndex = '10';
setTimeout(() => { const cleanup = () => {
pieceEl.style.transition = ''; pieceEl.style.transition = '';
pieceEl.style.transform = ''; pieceEl.style.transform = '';
pieceEl.style.zIndex = '';
if (callback) callback(); if (callback) callback();
}, 200); };
setTimeout(cleanup, 210);
},
// Enable/disable board interaction
setEnabled(val) {
this.enabled = val;
} }
}; };
// EL3AB Game Controller - Bot Play, Timers, Move Submission // EL3AB Game Controller PRO - Bot Play, Pro Timers, Move Stack, Sound, Post-game
const Game = { const Game = {
chess: null, chess: null,
playerColor: 'w', playerColor: 'w',
botId: null, botId: null,
gameId: null, gameId: null,
isGameOver: false,
moveHistory: [],
rated: true,
// Pro Timer
timeControl: 600, timeControl: 600,
increment: 0, increment: 0,
timerMode: 'fischer', // 'fischer', 'bronstein', 'delay'
clockWhite: 0, clockWhite: 0,
clockBlack: 0, clockBlack: 0,
clockInterval: null, clockInterval: null,
activeClock: null, activeClock: null,
isGameOver: false, moveStartTime: 0,
moveHistory: [], moveTimesWhite: [],
rated: true, moveTimesBlack: [],
lastTickTime: 0,
delayRemaining: 0,
lowTimeWarned: { 30: false, 10: false, 5: false },
// Audio context (lazy)
audioCtx: null,
// Move navigation
currentMoveIndex: -1,
// Opening book (common openings)
openings: {
'e4': 'King Pawn',
'e4 e5': 'Open Game',
'e4 e5 Nf3': 'King Knight Opening',
'e4 e5 Nf3 Nc6': 'Four Knights Setup',
'e4 e5 Nf3 Nc6 Bb5': 'Ruy Lopez',
'e4 e5 Nf3 Nc6 Bc4': 'Italian Game',
'e4 e5 Nf3 Nf6': 'Petrov Defense',
'e4 c5': 'Sicilian Defense',
'e4 c5 Nf3': 'Open Sicilian',
'e4 c5 Nf3 d6': 'Sicilian Najdorf Setup',
'e4 c5 Nf3 Nc6': 'Sicilian Classical',
'e4 e6': 'French Defense',
'e4 c6': 'Caro-Kann Defense',
'e4 d5': 'Scandinavian Defense',
'd4': 'Queen Pawn',
'd4 d5': 'Closed Game',
'd4 d5 c4': 'Queen\'s Gambit',
'd4 d5 c4 e6': 'Queen\'s Gambit Declined',
'd4 d5 c4 dxc4': 'Queen\'s Gambit Accepted',
'd4 Nf6': 'Indian Defense',
'd4 Nf6 c4': 'Indian Systems',
'd4 Nf6 c4 g6': 'King\'s Indian Defense',
'd4 Nf6 c4 e6': 'Nimzo/Queen\'s Indian',
'd4 Nf6 c4 e6 Nc3 Bb4': 'Nimzo-Indian Defense',
'Nf3': 'Reti Opening',
'c4': 'English Opening',
'c4 e5': 'Reversed Sicilian',
'g3': 'King\'s Fianchetto',
'e4 d6': 'Pirc Defense',
'e4 g6': 'Modern Defense'
},
async start(options = {}) { async start(options = {}) {
this.playerColor = options.color || 'w'; this.playerColor = options.color || 'w';
this.botId = options.botId || 'amina'; this.botId = options.botId || 'amina';
this.timeControl = options.time || 600; this.timeControl = options.time || 600;
this.increment = options.increment || 0; this.increment = options.increment || 0;
this.timerMode = options.timerMode || 'fischer';
this.rated = options.rated !== false; this.rated = options.rated !== false;
this.clockWhite = this.timeControl; this.clockWhite = this.timeControl;
this.clockBlack = this.timeControl; this.clockBlack = this.timeControl;
this.isGameOver = false; this.isGameOver = false;
this.moveHistory = []; this.moveHistory = [];
this.activeClock = null; this.activeClock = null;
this.moveTimesWhite = [];
this.moveTimesBlack = [];
this.moveStartTime = 0;
this.delayRemaining = 0;
this.lowTimeWarned = { 30: false, 10: false, 5: false };
this.currentMoveIndex = -1;
this.chess = new Chess(); this.chess = new Chess();
Board.init('board', { Board.init('board', {
flipped: this.playerColor === 'b', flipped: this.playerColor === 'b',
playerColor: this.playerColor,
onMove: (move) => this.handlePlayerMove(move) onMove: (move) => this.handlePlayerMove(move)
}); });
Board.setPosition(this.chess); Board.setPosition(this.chess);
Board.pushPosition(this.chess.fen());
this.updateClockDisplay(); this.updateClockDisplay();
this.updateMoveList(); this.updateMoveList();
this.updateStatus(); this.updateStatus();
this.bindControls();
const res = await App.fetch('/api/game', { const res = await App.fetch('/api/game', {
method: 'POST', method: 'POST',
...@@ -63,6 +122,22 @@ const Game = { ...@@ -63,6 +122,22 @@ const Game = {
} }
}, },
bindControls() {
const resignBtn = document.getElementById('btn-resign');
const drawBtn = document.getElementById('btn-draw');
const rematchBtn = document.getElementById('btn-rematch');
const pgnBtn = document.getElementById('btn-pgn');
const fenBtn = document.getElementById('btn-fen');
const flipBtn = document.getElementById('btn-flip');
if (resignBtn) resignBtn.onclick = () => this.resign();
if (drawBtn) drawBtn.onclick = () => this.offerDraw();
if (rematchBtn) rematchBtn.onclick = () => this.rematch();
if (pgnBtn) pgnBtn.onclick = () => this.exportPGN();
if (fenBtn) fenBtn.onclick = () => this.copyFEN();
if (flipBtn) flipBtn.onclick = () => Board.flip();
},
handlePlayerMove(move) { handlePlayerMove(move) {
if (this.isGameOver) return; if (this.isGameOver) return;
if (this.chess.turn() !== this.playerColor) return; if (this.chess.turn() !== this.playerColor) return;
...@@ -75,15 +150,21 @@ const Game = { ...@@ -75,15 +150,21 @@ const Game = {
if (!result) return; if (!result) return;
// Track move time
const moveTime = this.recordMoveTime(this.playerColor);
this.moveHistory.push(result); this.moveHistory.push(result);
this.currentMoveIndex = this.moveHistory.length - 1;
Board.lastMove = { from: move.from, to: move.to };
Board.setPosition(this.chess); Board.setPosition(this.chess);
this.addIncrement(this.playerColor); Board.pushPosition(this.chess.fen());
this.applyIncrement(this.playerColor);
this.switchClock(); this.switchClock();
this.updateMoveList(); this.updateMoveList();
this.updateStatus(); this.updateStatus();
this.playMoveSound(result); this.playMoveSound(result);
this.submitMove(result); this.submitMove(result, moveTime);
if (this.chess.game_over()) { if (this.chess.game_over()) {
this.endGame(); this.endGame();
...@@ -92,8 +173,22 @@ const Game = { ...@@ -92,8 +173,22 @@ const Game = {
} }
}, },
// Called when it becomes the player's turn (after bot moves)
checkPremove() {
const premove = Board.tryExecutePremove();
if (premove) {
// Small delay so animation looks natural
setTimeout(() => {
this.handlePlayerMove(premove);
}, 50);
return true;
}
return false;
},
async requestBotMove() { async requestBotMove() {
this.showThinking(true); this.showThinking(true);
Board.setEnabled(false);
const moves = this.chess.history(); const moves = this.chess.history();
try { try {
...@@ -117,30 +212,42 @@ const Game = { ...@@ -117,30 +212,42 @@ const Game = {
const promotion = res.move.length > 4 ? res.move[4] : undefined; const promotion = res.move.length > 4 ? res.move[4] : undefined;
Board.animateMove(from, to, () => { Board.animateMove(from, to, () => {
const result = this.chess.move({ from, to, promotion }); const result = this.chess.move({ from: from, to: to, promotion: promotion });
if (result) { if (result) {
const botColor = this.playerColor === 'w' ? 'b' : 'w';
this.recordMoveTime(botColor);
this.moveHistory.push(result); this.moveHistory.push(result);
Board.lastMove = { from, to }; this.currentMoveIndex = this.moveHistory.length - 1;
Board.lastMove = { from: from, to: to };
Board.setPosition(this.chess); Board.setPosition(this.chess);
this.addIncrement(this.playerColor === 'w' ? 'b' : 'w'); Board.pushPosition(this.chess.fen());
this.applyIncrement(botColor);
this.switchClock(); this.switchClock();
this.updateMoveList(); this.updateMoveList();
this.updateStatus(); this.updateStatus();
this.playMoveSound(result); this.playMoveSound(result);
Board.setEnabled(true);
if (this.chess.game_over()) { if (this.chess.game_over()) {
this.endGame(); this.endGame();
} else {
// Try to execute pre-move
this.checkPremove();
} }
} }
}); });
} else {
Board.setEnabled(true);
} }
} catch (e) { } catch (e) {
this.showThinking(false); this.showThinking(false);
App.toast('خطأ في الاتصال بالبوت', 'error'); Board.setEnabled(true);
App.toast('Connection error', 'error');
} }
}, },
submitMove(move) { submitMove(move, moveTime) {
App.fetch('/api/game', { App.fetch('/api/game', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
...@@ -149,32 +256,68 @@ const Game = { ...@@ -149,32 +256,68 @@ const Game = {
move: move.san, move: move.san,
fen: this.chess.fen(), fen: this.chess.fen(),
clock_white: Math.round(this.clockWhite), clock_white: Math.round(this.clockWhite),
clock_black: Math.round(this.clockBlack) clock_black: Math.round(this.clockBlack),
move_time: moveTime
}) })
}); });
}, },
// --- Pro Timer System ---
startClock(color) { startClock(color) {
this.stopClock(); this.stopClock();
this.activeClock = color; this.activeClock = color;
this.moveStartTime = performance.now();
this.lastTickTime = performance.now();
this.delayRemaining = (this.timerMode === 'delay' || this.timerMode === 'bronstein') ? this.increment : 0;
this.updateClockDisplay(); this.updateClockDisplay();
this.clockInterval = setInterval(() => { this.clockInterval = setInterval(() => {
const now = performance.now();
const elapsed = (now - this.lastTickTime) / 1000;
this.lastTickTime = now;
if (this.timerMode === 'delay' && this.delayRemaining > 0) {
// Simple delay: countdown delay first, then clock
this.delayRemaining -= elapsed;
if (this.delayRemaining < 0) {
// Overflow into clock time
const overflow = -this.delayRemaining;
this.delayRemaining = 0;
if (this.activeClock === 'w') { if (this.activeClock === 'w') {
this.clockWhite -= 0.1; this.clockWhite -= overflow;
if (this.clockWhite <= 0) { } else {
this.clockWhite = 0; this.clockBlack -= overflow;
this.flagged('w'); }
}
} else if (this.timerMode === 'bronstein') {
// Bronstein: delay is "given back" at end of move, up to increment
if (this.activeClock === 'w') {
this.clockWhite -= elapsed;
} else {
this.clockBlack -= elapsed;
} }
} else { } else {
this.clockBlack -= 0.1; // Fischer: just decrement
if (this.clockBlack <= 0) { if (this.activeClock === 'w') {
this.clockWhite -= elapsed;
} else {
this.clockBlack -= elapsed;
}
}
// Check flag
if (this.activeClock === 'w' && this.clockWhite <= 0) {
this.clockWhite = 0;
this.flagged('w');
} else if (this.activeClock === 'b' && this.clockBlack <= 0) {
this.clockBlack = 0; this.clockBlack = 0;
this.flagged('b'); this.flagged('b');
} }
}
// Low time warnings
this.checkLowTimeWarning();
this.updateClockDisplay(); this.updateClockDisplay();
}, 100); }, 50);
}, },
stopClock() { stopClock() {
...@@ -189,10 +332,51 @@ const Game = { ...@@ -189,10 +332,51 @@ const Game = {
this.startClock(next); this.startClock(next);
}, },
addIncrement(color) { applyIncrement(color) {
if (this.increment <= 0) return; if (this.increment <= 0) return;
if (this.timerMode === 'fischer') {
// Fischer: add full increment
if (color === 'w') this.clockWhite += this.increment; if (color === 'w') this.clockWhite += this.increment;
else this.clockBlack += this.increment; else this.clockBlack += this.increment;
} else if (this.timerMode === 'bronstein') {
// Bronstein: add back time spent, up to increment
const moveTime = (performance.now() - this.moveStartTime) / 1000;
const bonus = Math.min(moveTime, this.increment);
if (color === 'w') this.clockWhite += bonus;
else this.clockBlack += bonus;
}
// Simple delay: no increment added (delay happens at start of move)
},
recordMoveTime(color) {
const moveTime = (performance.now() - this.moveStartTime) / 1000;
if (color === 'w') {
this.moveTimesWhite.push(moveTime);
} else {
this.moveTimesBlack.push(moveTime);
}
return moveTime;
},
checkLowTimeWarning() {
const settings = this.getSettings();
if (settings.sound === false) return;
const playerTime = this.playerColor === 'w' ? this.clockWhite : this.clockBlack;
if (this.activeClock === this.playerColor) {
if (playerTime <= 5 && !this.lowTimeWarned[5]) {
this.lowTimeWarned[5] = true;
this.playLowTimeSound(3);
} else if (playerTime <= 10 && !this.lowTimeWarned[10]) {
this.lowTimeWarned[10] = true;
this.playLowTimeSound(2);
} else if (playerTime <= 30 && !this.lowTimeWarned[30]) {
this.lowTimeWarned[30] = true;
this.playLowTimeSound(1);
}
}
}, },
flagged(color) { flagged(color) {
...@@ -220,16 +404,25 @@ const Game = { ...@@ -220,16 +404,25 @@ const Game = {
}, },
formatTime(seconds) { formatTime(seconds) {
if (seconds <= 0) return '0:00'; if (seconds <= 0) return '0:00.0';
const mins = Math.floor(seconds / 60); const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60); const secs = Math.floor(seconds % 60);
if (seconds < 60) {
if (seconds < 3) {
// Show hundredths below 3s
const hundredths = Math.floor((seconds % 1) * 100);
return secs + '.' + hundredths.toString().padStart(2, '0');
} else if (seconds < 10) {
// Show tenths below 10s
const tenths = Math.floor((seconds % 1) * 10); const tenths = Math.floor((seconds % 1) * 10);
return `${secs}.${tenths}`; return secs + '.' + tenths;
} else if (seconds < 60) {
return '0:' + secs.toString().padStart(2, '0');
} }
return `${mins}:${secs.toString().padStart(2, '0')}`; return mins + ':' + secs.toString().padStart(2, '0');
}, },
// --- Pro Move List ---
updateMoveList() { updateMoveList() {
const list = document.getElementById('move-list'); const list = document.getElementById('move-list');
if (!list) return; if (!list) return;
...@@ -247,38 +440,84 @@ const Game = { ...@@ -247,38 +440,84 @@ const Game = {
pair.appendChild(num); pair.appendChild(num);
const white = document.createElement('span'); const white = document.createElement('span');
white.className = 'move' + (i === history.length - 1 ? ' current' : ''); white.className = 'move' + (i === this.currentMoveIndex ? ' current' : '');
white.textContent = history[i]; white.textContent = history[i];
white.dataset.moveIndex = i;
white.onclick = () => this.jumpToMove(i);
pair.appendChild(white); pair.appendChild(white);
if (history[i + 1]) { if (history[i + 1]) {
const black = document.createElement('span'); const black = document.createElement('span');
black.className = 'move' + (i + 1 === history.length - 1 ? ' current' : ''); black.className = 'move' + (i + 1 === this.currentMoveIndex ? ' current' : '');
black.textContent = history[i + 1]; black.textContent = history[i + 1];
black.dataset.moveIndex = i + 1;
black.onclick = () => this.jumpToMove(i + 1);
pair.appendChild(black); pair.appendChild(black);
} }
list.appendChild(pair); list.appendChild(pair);
} }
// Auto-scroll
list.scrollTop = list.scrollHeight; list.scrollTop = list.scrollHeight;
// Show opening name
this.updateOpeningName(history);
}, },
updateOpeningName(history) {
const openingEl = document.getElementById('opening-name');
if (!openingEl) return;
if (history.length === 0) {
openingEl.textContent = '';
return;
}
// Try progressively shorter sequences to find opening
let opening = '';
const maxCheck = Math.min(history.length, 12);
for (let len = maxCheck; len >= 1; len--) {
const seq = history.slice(0, len).join(' ');
if (this.openings[seq]) {
opening = this.openings[seq];
break;
}
}
openingEl.textContent = opening;
},
jumpToMove(index) {
this.currentMoveIndex = index;
// Navigate board to this position
// positionHistory[0] is starting position, [1] is after first move, etc.
Board.navigateTo(index + 1);
// Re-highlight current move in list
const list = document.getElementById('move-list');
if (!list) return;
const moves = list.querySelectorAll('.move');
moves.forEach(m => m.classList.remove('current'));
const target = list.querySelector('[data-move-index="' + index + '"]');
if (target) target.classList.add('current');
},
// --- Game Status ---
updateStatus() { updateStatus() {
const status = document.getElementById('game-status'); const status = document.getElementById('game-status');
if (!status) return; if (!status) return;
if (this.chess.in_checkmate()) { if (this.chess.in_checkmate()) {
const winner = this.chess.turn() === 'w' ? 'b' : 'w'; const winner = this.chess.turn() === 'w' ? 'b' : 'w';
status.textContent = winner === this.playerColor ? 'كش ملك - فوز!' : 'كش ملك - خسارة'; status.textContent = winner === this.playerColor ? 'Checkmate - You win!' : 'Checkmate - You lose';
} else if (this.chess.in_check()) { } else if (this.chess.in_check()) {
status.textContent = 'كش!'; status.textContent = 'Check!';
} else if (this.chess.in_draw()) { } else if (this.chess.in_draw()) {
status.textContent = 'تعادل'; status.textContent = 'Draw';
} else if (this.chess.in_stalemate()) { } else if (this.chess.in_stalemate()) {
status.textContent = 'تعادل - بات'; status.textContent = 'Stalemate';
} else { } else {
status.textContent = this.chess.turn() === this.playerColor ? 'دورك' : 'دور الخصم'; status.textContent = this.chess.turn() === this.playerColor ? 'Your turn' : 'Opponent thinking...';
} }
}, },
...@@ -287,33 +526,40 @@ const Game = { ...@@ -287,33 +526,40 @@ const Game = {
if (el) el.style.display = show ? 'flex' : 'none'; if (el) el.style.display = show ? 'flex' : 'none';
}, },
// --- End Game ---
endGame(reason, winner) { endGame(reason, winner) {
if (this.isGameOver) return; if (this.isGameOver) return;
this.isGameOver = true; this.isGameOver = true;
this.stopClock(); this.stopClock();
Board.setEnabled(false);
Board.clearPremove();
let title, subtitle; let title, subtitle;
if (reason === 'timeout') { if (reason === 'timeout') {
title = winner === this.playerColor ? 'فوز بالوقت!' : 'خسارة بالوقت'; title = winner === this.playerColor ? 'Win on time!' : 'Lost on time';
subtitle = 'انتهى الوقت'; subtitle = 'Time ran out';
} else if (this.chess.in_checkmate()) { } else if (this.chess.in_checkmate()) {
const w = this.chess.turn() === 'w' ? 'b' : 'w'; const w = this.chess.turn() === 'w' ? 'b' : 'w';
title = w === this.playerColor ? 'فوز!' : 'خسارة'; title = w === this.playerColor ? 'You win!' : 'You lose';
subtitle = 'كش ملك'; subtitle = 'Checkmate';
} else if (this.chess.in_stalemate()) { } else if (this.chess.in_stalemate()) {
title = 'تعادل'; title = 'Draw';
subtitle = 'بات'; subtitle = 'Stalemate';
} else if (this.chess.in_draw()) { } else if (this.chess.in_draw()) {
title = 'تعادل'; title = 'Draw';
subtitle = this.chess.in_threefold_repetition() ? 'تكرار' : 'مادة غير كافية'; subtitle = 'Insufficient material';
} else if (reason === 'resign') { } else if (reason === 'resign') {
title = winner === this.playerColor ? 'فوز!' : 'خسارة'; title = winner === this.playerColor ? 'You win!' : 'You lose';
subtitle = 'استسلام'; subtitle = 'Resignation';
} else if (reason === 'draw') {
title = 'Draw';
subtitle = 'By agreement';
} else { } else {
title = 'انتهت المباراة'; title = 'Game over';
subtitle = ''; subtitle = '';
} }
this.playGameEndSound(winner === this.playerColor);
this.showResult(title, subtitle); this.showResult(title, subtitle);
const result = winner === this.playerColor ? 'win' : (winner ? 'loss' : 'draw'); const result = winner === this.playerColor ? 'win' : (winner ? 'loss' : 'draw');
...@@ -334,49 +580,198 @@ const Game = { ...@@ -334,49 +580,198 @@ const Game = {
const wrapper = document.querySelector('.board-wrapper'); const wrapper = document.querySelector('.board-wrapper');
if (!wrapper) return; if (!wrapper) return;
// Stats
const totalMoves = this.moveHistory.length;
const playerMoves = this.playerColor === 'w' ? this.moveTimesWhite : this.moveTimesBlack;
const avgMoveTime = playerMoves.length > 0 ? (playerMoves.reduce((a, b) => a + b, 0) / playerMoves.length).toFixed(1) : '0';
const totalTimeUsed = playerMoves.length > 0 ? playerMoves.reduce((a, b) => a + b, 0).toFixed(1) : '0';
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.className = 'game-result'; overlay.className = 'game-result';
overlay.innerHTML = ` overlay.innerHTML = '<div class="game-result-title">' + title + '</div>' +
<div class="game-result-title">${title}</div> '<div class="game-result-subtitle">' + subtitle + '</div>' +
<div class="game-result-subtitle">${subtitle}</div> '<div class="game-result-stats">' +
<div class="game-controls" style="margin-top:16px;"> '<div class="stat-row"><span>Moves</span><span>' + totalMoves + '</span></div>' +
<button class="btn btn-cyan" onclick="window.location.href='/play'">العب مرة اخرى</button> '<div class="stat-row"><span>Avg move time</span><span>' + avgMoveTime + 's</span></div>' +
<button class="btn btn-ghost" onclick="window.location.href='/'">الرئيسية</button> '<div class="stat-row"><span>Time used</span><span>' + totalTimeUsed + 's</span></div>' +
</div> '</div>' +
`; '<div class="game-controls" style="margin-top:16px;">' +
'<button class="btn btn-cyan" id="btn-rematch-result">Rematch</button>' +
'<button class="btn btn-ghost" onclick="window.location.href=\'/play\'">Back</button>' +
'</div>';
wrapper.appendChild(overlay); wrapper.appendChild(overlay);
const rematchResultBtn = document.getElementById('btn-rematch-result');
if (rematchResultBtn) {
rematchResultBtn.onclick = () => this.rematch();
}
}, },
// --- Actions ---
resign() { resign() {
if (this.isGameOver) return; if (this.isGameOver) return;
if (!confirm('هل تريد الاستسلام؟')) return; if (!confirm('Resign this game?')) return;
const winner = this.playerColor === 'w' ? 'b' : 'w'; const winner = this.playerColor === 'w' ? 'b' : 'w';
this.endGame('resign', winner); this.endGame('resign', winner);
}, },
offerDraw() { offerDraw() {
if (this.isGameOver) return; if (this.isGameOver) return;
if (!confirm('Offer a draw?')) return;
// In bot games, draws are auto-accepted when position is drawn or agreed
this.endGame('draw', null); this.endGame('draw', null);
}, },
playMoveSound(move) { rematch() {
// Remove result overlay
const overlay = document.querySelector('.game-result');
if (overlay) overlay.remove();
// Restart with same settings but swap colors
this.start({
color: this.playerColor === 'w' ? 'b' : 'w',
botId: this.botId,
time: this.timeControl,
increment: this.increment,
timerMode: this.timerMode,
rated: this.rated
});
},
exportPGN() {
const pgn = this.chess.pgn();
if (!pgn) {
App.toast('No moves to export', 'info');
return;
}
if (navigator.clipboard) {
navigator.clipboard.writeText(pgn).then(() => {
App.toast('PGN copied to clipboard', 'success');
}).catch(() => {
this.fallbackCopy(pgn, 'PGN');
});
} else {
this.fallbackCopy(pgn, 'PGN');
}
},
copyFEN() {
const fen = this.chess.fen();
if (navigator.clipboard) {
navigator.clipboard.writeText(fen).then(() => {
App.toast('FEN copied to clipboard', 'success');
}).catch(() => {
this.fallbackCopy(fen, 'FEN');
});
} else {
this.fallbackCopy(fen, 'FEN');
}
},
fallbackCopy(text, label) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
App.toast(label + ' copied to clipboard', 'success');
} catch (e) {
App.toast('Could not copy ' + label, 'error');
}
document.body.removeChild(ta);
},
// --- Sound System (Web Audio API oscillator tones) ---
getAudioCtx() {
if (!this.audioCtx) {
try {
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
} catch (e) {
return null;
}
}
return this.audioCtx;
},
getSettings() {
try {
return JSON.parse(localStorage.getItem('el3ab_settings') || '{}');
} catch (e) {
return {};
}
},
isSoundEnabled() {
const settings = this.getSettings();
return settings.sound !== false;
},
playTone(freq, duration, gain, type) {
if (!this.isSoundEnabled()) return;
const ctx = this.getAudioCtx();
if (!ctx) return;
try { try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const osc = ctx.createOscillator(); const osc = ctx.createOscillator();
const gain = ctx.createGain(); const gainNode = ctx.createGain();
osc.connect(gain); osc.connect(gainNode);
gain.connect(ctx.destination); gainNode.connect(ctx.destination);
gain.gain.value = 0.1;
osc.type = type || 'sine';
if (move.captured) { osc.frequency.setValueAtTime(freq, ctx.currentTime);
osc.frequency.value = 200; gainNode.gain.setValueAtTime(gain || 0.1, ctx.currentTime);
gain.gain.value = 0.15; gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + duration);
} catch (e) {}
},
playMoveSound(move) {
if (!this.isSoundEnabled()) return;
if (move.san === 'O-O' || move.san === 'O-O-O') {
// Castle sound: two quick tones
this.playTone(500, 0.06, 0.12, 'sine');
setTimeout(() => this.playTone(600, 0.06, 0.12, 'sine'), 70);
} else if (move.san && move.san.includes('+')) {
// Check sound: sharp high tone
this.playTone(880, 0.15, 0.15, 'square');
} else if (move.captured) {
// Capture sound: percussive low
this.playTone(180, 0.12, 0.18, 'sawtooth');
} else { } else {
osc.frequency.value = 400; // Normal move: clean click
this.playTone(440, 0.06, 0.1, 'sine');
} }
},
osc.start(); playGameEndSound(isWin) {
osc.stop(ctx.currentTime + 0.08); if (!this.isSoundEnabled()) return;
} catch(e) {}
if (isWin) {
// Ascending fanfare
this.playTone(523, 0.15, 0.12, 'sine');
setTimeout(() => this.playTone(659, 0.15, 0.12, 'sine'), 150);
setTimeout(() => this.playTone(784, 0.3, 0.15, 'sine'), 300);
} else {
// Descending sad tones
this.playTone(400, 0.2, 0.1, 'sine');
setTimeout(() => this.playTone(300, 0.2, 0.1, 'sine'), 200);
setTimeout(() => this.playTone(200, 0.4, 0.1, 'sine'), 400);
}
},
playLowTimeSound(urgency) {
if (!this.isSoundEnabled()) return;
// urgency: 1=30s, 2=10s, 3=5s
const freq = 600 + urgency * 200;
const count = urgency;
for (let i = 0; i < count; i++) {
setTimeout(() => this.playTone(freq, 0.08, 0.15, 'square'), i * 120);
}
} }
}; };
/**
* Chess Opening Database
* Lookup by move prefix (space-separated SAN moves)
*/
const Openings = (function() {
// Database: key = move sequence, value = {eco, name, name_ar}
const db = {
// === KING'S PAWN (1.e4) ===
'e4': {eco: 'B00', name: "King's Pawn", name_ar: 'افتتاح بيدق الملك'},
'e4 e5': {eco: 'C20', name: "King's Pawn Game", name_ar: 'لعبة بيدق الملك'},
'e4 e5 Nf3': {eco: 'C40', name: "King's Knight Opening", name_ar: 'افتتاح حصان الملك'},
'e4 e5 Nf3 Nc6': {eco: 'C44', name: "King's Knight Opening", name_ar: 'افتتاح حصان الملك'},
// Ruy Lopez
'e4 e5 Nf3 Nc6 Bb5': {eco: 'C60', name: 'Ruy Lopez', name_ar: 'روي لوبيز'},
'e4 e5 Nf3 Nc6 Bb5 a6': {eco: 'C68', name: 'Ruy Lopez: Morphy Defense', name_ar: 'روي لوبيز: دفاع مورفي'},
'e4 e5 Nf3 Nc6 Bb5 a6 Ba4': {eco: 'C70', name: 'Ruy Lopez: Morphy Defense', name_ar: 'روي لوبيز: دفاع مورفي'},
'e4 e5 Nf3 Nc6 Bb5 a6 Ba4 Nf6': {eco: 'C72', name: 'Ruy Lopez: Morphy Defense', name_ar: 'روي لوبيز: دفاع مورفي'},
'e4 e5 Nf3 Nc6 Bb5 a6 Ba4 Nf6 O-O': {eco: 'C78', name: 'Ruy Lopez: Morphy Defense', name_ar: 'روي لوبيز: دفاع مورفي'},
'e4 e5 Nf3 Nc6 Bb5 a6 Ba4 Nf6 O-O Be7': {eco: 'C84', name: 'Ruy Lopez: Closed', name_ar: 'روي لوبيز: المغلق'},
'e4 e5 Nf3 Nc6 Bb5 a6 Ba4 Nf6 O-O Be7 Re1 b5 Bb3 O-O c3 d5': {eco: 'C89', name: 'Ruy Lopez: Marshall Attack', name_ar: 'روي لوبيز: هجوم مارشال'},
'e4 e5 Nf3 Nc6 Bb5 Nf6': {eco: 'C65', name: 'Ruy Lopez: Berlin Defense', name_ar: 'روي لوبيز: دفاع برلين'},
'e4 e5 Nf3 Nc6 Bb5 Nf6 O-O Nxe4': {eco: 'C67', name: 'Ruy Lopez: Berlin Defense, Rio de Janeiro', name_ar: 'روي لوبيز: دفاع برلين'},
// Italian Game
'e4 e5 Nf3 Nc6 Bc4': {eco: 'C50', name: 'Italian Game', name_ar: 'اللعبة الايطالية'},
'e4 e5 Nf3 Nc6 Bc4 Bc5': {eco: 'C50', name: 'Italian Game: Giuoco Piano', name_ar: 'اللعبة الايطالية: جيوكو بيانو'},
'e4 e5 Nf3 Nc6 Bc4 Bc5 b4': {eco: 'C51', name: 'Italian Game: Evans Gambit', name_ar: 'اللعبة الايطالية: غامبيت ايفانز'},
'e4 e5 Nf3 Nc6 Bc4 Bc5 c3': {eco: 'C54', name: 'Italian Game: Classical', name_ar: 'اللعبة الايطالية: الكلاسيكي'},
'e4 e5 Nf3 Nc6 Bc4 Nf6': {eco: 'C55', name: 'Italian Game: Two Knights Defense', name_ar: 'اللعبة الايطالية: دفاع الحصانين'},
'e4 e5 Nf3 Nc6 Bc4 Nf6 Ng5': {eco: 'C57', name: 'Italian Game: Fried Liver Attack', name_ar: 'اللعبة الايطالية: هجوم الكبد المقلي'},
// Scotch Game
'e4 e5 Nf3 Nc6 d4': {eco: 'C44', name: 'Scotch Game', name_ar: 'اللعبة الاسكتلندية'},
'e4 e5 Nf3 Nc6 d4 exd4': {eco: 'C44', name: 'Scotch Game', name_ar: 'اللعبة الاسكتلندية'},
'e4 e5 Nf3 Nc6 d4 exd4 Nxd4': {eco: 'C45', name: 'Scotch Game', name_ar: 'اللعبة الاسكتلندية'},
'e4 e5 Nf3 Nc6 d4 exd4 Bc4': {eco: 'C44', name: 'Scotch Gambit', name_ar: 'غامبيت اسكتلندي'},
// Petrov's Defense
'e4 e5 Nf3 Nf6': {eco: 'C42', name: "Petrov's Defense", name_ar: 'دفاع بتروف'},
'e4 e5 Nf3 Nf6 Nxe5': {eco: 'C42', name: "Petrov's Defense: Classical", name_ar: 'دفاع بتروف: الكلاسيكي'},
'e4 e5 Nf3 Nf6 d4': {eco: 'C43', name: "Petrov's Defense: Steinitz Attack", name_ar: 'دفاع بتروف: هجوم شتاينتز'},
// Philidor Defense
'e4 e5 Nf3 d6': {eco: 'C41', name: 'Philidor Defense', name_ar: 'دفاع فيليدور'},
'e4 e5 Nf3 d6 d4': {eco: 'C41', name: 'Philidor Defense', name_ar: 'دفاع فيليدور'},
// King's Gambit
'e4 e5 f4': {eco: 'C30', name: "King's Gambit", name_ar: 'غامبيت الملك'},
'e4 e5 f4 exf4': {eco: 'C33', name: "King's Gambit Accepted", name_ar: 'غامبيت الملك المقبول'},
'e4 e5 f4 Bc5': {eco: 'C30', name: "King's Gambit Declined", name_ar: 'غامبيت الملك المرفوض'},
// Vienna Game
'e4 e5 Nc3': {eco: 'C25', name: 'Vienna Game', name_ar: 'لعبة فيينا'},
'e4 e5 Nc3 Nf6': {eco: 'C26', name: 'Vienna Game', name_ar: 'لعبة فيينا'},
'e4 e5 Nc3 Nc6 f4': {eco: 'C25', name: 'Vienna Gambit', name_ar: 'غامبيت فيينا'},
// Four Knights
'e4 e5 Nf3 Nc6 Nc3 Nf6': {eco: 'C46', name: 'Four Knights Game', name_ar: 'لعبة الفرسان الاربعة'},
// === SICILIAN DEFENSE ===
'e4 c5': {eco: 'B20', name: 'Sicilian Defense', name_ar: 'الدفاع الصقلي'},
'e4 c5 Nf3': {eco: 'B27', name: 'Sicilian Defense', name_ar: 'الدفاع الصقلي'},
'e4 c5 Nf3 d6': {eco: 'B50', name: 'Sicilian Defense', name_ar: 'الدفاع الصقلي'},
'e4 c5 Nf3 d6 d4': {eco: 'B50', name: 'Sicilian Defense: Open', name_ar: 'الدفاع الصقلي: المفتوح'},
'e4 c5 Nf3 d6 d4 cxd4 Nxd4': {eco: 'B50', name: 'Sicilian Defense: Open', name_ar: 'الدفاع الصقلي: المفتوح'},
'e4 c5 Nf3 d6 d4 cxd4 Nxd4 Nf6 Nc3': {eco: 'B56', name: 'Sicilian Defense: Open', name_ar: 'الدفاع الصقلي: المفتوح'},
'e4 c5 Nf3 d6 d4 cxd4 Nxd4 Nf6 Nc3 a6': {eco: 'B90', name: 'Sicilian Defense: Najdorf', name_ar: 'الدفاع الصقلي: نايدورف'},
'e4 c5 Nf3 d6 d4 cxd4 Nxd4 Nf6 Nc3 a6 Bg5': {eco: 'B94', name: 'Sicilian Najdorf: Classical', name_ar: 'الصقلي نايدورف: الكلاسيكي'},
'e4 c5 Nf3 d6 d4 cxd4 Nxd4 Nf6 Nc3 a6 Be2': {eco: 'B92', name: 'Sicilian Najdorf: Opocensky', name_ar: 'الصقلي نايدورف: اوبوتشنسكي'},
'e4 c5 Nf3 d6 d4 cxd4 Nxd4 Nf6 Nc3 g6': {eco: 'B70', name: 'Sicilian Defense: Dragon', name_ar: 'الدفاع الصقلي: التنين'},
'e4 c5 Nf3 d6 d4 cxd4 Nxd4 Nf6 Nc3 g6 Be3 Bg7 f3': {eco: 'B76', name: 'Sicilian Dragon: Yugoslav Attack', name_ar: 'الصقلي التنين: الهجوم اليوغسلافي'},
'e4 c5 Nf3 d6 d4 cxd4 Nxd4 Nf6 Nc3 e6': {eco: 'B80', name: 'Sicilian Defense: Scheveningen', name_ar: 'الدفاع الصقلي: شيفننغن'},
'e4 c5 Nf3 Nc6': {eco: 'B30', name: 'Sicilian Defense', name_ar: 'الدفاع الصقلي'},
'e4 c5 Nf3 Nc6 d4 cxd4 Nxd4 Nf6 Nc3 e5': {eco: 'B33', name: 'Sicilian Defense: Sveshnikov', name_ar: 'الدفاع الصقلي: سفيشنيكوف'},
'e4 c5 Nf3 e6': {eco: 'B40', name: 'Sicilian Defense', name_ar: 'الدفاع الصقلي'},
'e4 c5 Nf3 e6 d4 cxd4 Nxd4 Nc6': {eco: 'B45', name: 'Sicilian Defense: Taimanov', name_ar: 'الدفاع الصقلي: تايمانوف'},
'e4 c5 Nc3': {eco: 'B23', name: 'Sicilian Defense: Closed', name_ar: 'الدفاع الصقلي: المغلق'},
'e4 c5 c3': {eco: 'B22', name: 'Sicilian Defense: Alapin', name_ar: 'الدفاع الصقلي: الابين'},
'e4 c5 d4 cxd4 c3': {eco: 'B21', name: 'Sicilian Defense: Smith-Morra Gambit', name_ar: 'الدفاع الصقلي: غامبيت سميث-مورا'},
// === FRENCH DEFENSE ===
'e4 e6': {eco: 'C00', name: 'French Defense', name_ar: 'الدفاع الفرنسي'},
'e4 e6 d4': {eco: 'C00', name: 'French Defense', name_ar: 'الدفاع الفرنسي'},
'e4 e6 d4 d5': {eco: 'C01', name: 'French Defense', name_ar: 'الدفاع الفرنسي'},
'e4 e6 d4 d5 Nc3': {eco: 'C03', name: 'French Defense', name_ar: 'الدفاع الفرنسي'},
'e4 e6 d4 d5 Nc3 Bb4': {eco: 'C15', name: 'French Defense: Winawer', name_ar: 'الدفاع الفرنسي: فيناور'},
'e4 e6 d4 d5 Nc3 Bb4 e5': {eco: 'C16', name: 'French Winawer: Advance', name_ar: 'الفرنسي فيناور: المتقدم'},
'e4 e6 d4 d5 Nc3 Nf6': {eco: 'C10', name: 'French Defense: Classical', name_ar: 'الدفاع الفرنسي: الكلاسيكي'},
'e4 e6 d4 d5 Nc3 Nf6 Bg5': {eco: 'C13', name: 'French Defense: Classical, Burn', name_ar: 'الدفاع الفرنسي: الكلاسيكي بيرن'},
'e4 e6 d4 d5 e5': {eco: 'C02', name: 'French Defense: Advance', name_ar: 'الدفاع الفرنسي: المتقدم'},
'e4 e6 d4 d5 Nd2': {eco: 'C03', name: 'French Defense: Tarrasch', name_ar: 'الدفاع الفرنسي: تاراش'},
'e4 e6 d4 d5 exd5 exd5': {eco: 'C01', name: 'French Defense: Exchange', name_ar: 'الدفاع الفرنسي: التبادل'},
// === CARO-KANN ===
'e4 c6': {eco: 'B10', name: 'Caro-Kann Defense', name_ar: 'دفاع كارو-كان'},
'e4 c6 d4': {eco: 'B10', name: 'Caro-Kann Defense', name_ar: 'دفاع كارو-كان'},
'e4 c6 d4 d5': {eco: 'B12', name: 'Caro-Kann Defense', name_ar: 'دفاع كارو-كان'},
'e4 c6 d4 d5 Nc3': {eco: 'B15', name: 'Caro-Kann Defense', name_ar: 'دفاع كارو-كان'},
'e4 c6 d4 d5 Nc3 dxe4 Nxe4': {eco: 'B15', name: 'Caro-Kann: Classical', name_ar: 'كارو-كان: الكلاسيكي'},
'e4 c6 d4 d5 Nc3 dxe4 Nxe4 Bf5': {eco: 'B18', name: 'Caro-Kann: Classical', name_ar: 'كارو-كان: الكلاسيكي'},
'e4 c6 d4 d5 Nc3 dxe4 Nxe4 Nd7': {eco: 'B17', name: 'Caro-Kann: Steinitz', name_ar: 'كارو-كان: شتاينتز'},
'e4 c6 d4 d5 e5': {eco: 'B12', name: 'Caro-Kann: Advance', name_ar: 'كارو-كان: المتقدم'},
'e4 c6 d4 d5 exd5 cxd5': {eco: 'B13', name: 'Caro-Kann: Exchange', name_ar: 'كارو-كان: التبادل'},
'e4 c6 d4 d5 Nd2': {eco: 'B12', name: 'Caro-Kann: Short Variation', name_ar: 'كارو-كان: المختصر'},
// === SCANDINAVIAN ===
'e4 d5': {eco: 'B01', name: 'Scandinavian Defense', name_ar: 'الدفاع الاسكندنافي'},
'e4 d5 exd5 Qxd5': {eco: 'B01', name: 'Scandinavian Defense: Mieses-Kotroc', name_ar: 'الاسكندنافي: ميسيس-كوتروك'},
'e4 d5 exd5 Nf6': {eco: 'B01', name: 'Scandinavian Defense: Modern', name_ar: 'الاسكندنافي: الحديث'},
// === PIRC DEFENSE ===
'e4 d6': {eco: 'B07', name: 'Pirc Defense', name_ar: 'دفاع بيرك'},
'e4 d6 d4 Nf6': {eco: 'B07', name: 'Pirc Defense', name_ar: 'دفاع بيرك'},
'e4 d6 d4 Nf6 Nc3 g6': {eco: 'B07', name: 'Pirc Defense: Classical', name_ar: 'دفاع بيرك: الكلاسيكي'},
'e4 d6 d4 Nf6 Nc3 g6 f4': {eco: 'B09', name: 'Pirc Defense: Austrian Attack', name_ar: 'دفاع بيرك: الهجوم النمساوي'},
// === ALEKHINE'S DEFENSE ===
'e4 Nf6': {eco: 'B02', name: "Alekhine's Defense", name_ar: 'دفاع اليخين'},
'e4 Nf6 e5 Nd5': {eco: 'B03', name: "Alekhine's Defense", name_ar: 'دفاع اليخين'},
'e4 Nf6 e5 Nd5 d4 d6': {eco: 'B05', name: "Alekhine's Defense: Modern", name_ar: 'دفاع اليخين: الحديث'},
// === QUEEN'S PAWN (1.d4) ===
'd4': {eco: 'A45', name: "Queen's Pawn", name_ar: 'افتتاح بيدق الوزير'},
'd4 d5': {eco: 'D00', name: "Queen's Pawn Game", name_ar: 'لعبة بيدق الوزير'},
'd4 Nf6': {eco: 'A45', name: "Indian Defense", name_ar: 'الدفاع الهندي'},
// Queen's Gambit
'd4 d5 c4': {eco: 'D06', name: "Queen's Gambit", name_ar: 'غامبيت الملكة'},
'd4 d5 c4 e6': {eco: 'D30', name: "Queen's Gambit Declined", name_ar: 'غامبيت الملكة المرفوض'},
'd4 d5 c4 e6 Nc3': {eco: 'D31', name: "Queen's Gambit Declined", name_ar: 'غامبيت الملكة المرفوض'},
'd4 d5 c4 e6 Nc3 Nf6': {eco: 'D35', name: "Queen's Gambit Declined: Exchange", name_ar: 'غامبيت الملكة المرفوض: التبادل'},
'd4 d5 c4 e6 Nc3 Nf6 Bg5': {eco: 'D37', name: "Queen's Gambit Declined: Classical", name_ar: 'غامبيت الملكة المرفوض: الكلاسيكي'},
'd4 d5 c4 e6 Nf3 Nf6 Nc3 Be7 Bf4': {eco: 'D38', name: "Queen's Gambit Declined: Ragozin", name_ar: 'غامبيت الملكة المرفوض: راغوزين'},
'd4 d5 c4 dxc4': {eco: 'D20', name: "Queen's Gambit Accepted", name_ar: 'غامبيت الملكة المقبول'},
'd4 d5 c4 dxc4 Nf3': {eco: 'D25', name: "Queen's Gambit Accepted", name_ar: 'غامبيت الملكة المقبول'},
'd4 d5 c4 c6': {eco: 'D10', name: 'Slav Defense', name_ar: 'الدفاع السلافي'},
'd4 d5 c4 c6 Nf3 Nf6 Nc3': {eco: 'D15', name: 'Slav Defense', name_ar: 'الدفاع السلافي'},
'd4 d5 c4 c6 Nf3 Nf6 Nc3 dxc4': {eco: 'D17', name: 'Slav Defense: Czech', name_ar: 'الدفاع السلافي: التشيكي'},
'd4 d5 c4 c6 Nf3 Nf6 Nc3 e6': {eco: 'D45', name: 'Semi-Slav Defense', name_ar: 'الدفاع شبه السلافي'},
// London System
'd4 d5 Bf4': {eco: 'D00', name: 'London System', name_ar: 'نظام لندن'},
'd4 d5 Nf3 Nf6 Bf4': {eco: 'D00', name: 'London System', name_ar: 'نظام لندن'},
'd4 Nf6 Bf4': {eco: 'A45', name: 'London System', name_ar: 'نظام لندن'},
'd4 Nf6 Nf3 e6 Bf4': {eco: 'A46', name: 'London System', name_ar: 'نظام لندن'},
// King's Indian Defense
'd4 Nf6 c4 g6': {eco: 'E60', name: "King's Indian Defense", name_ar: 'الدفاع الهندي الملكي'},
'd4 Nf6 c4 g6 Nc3 Bg7': {eco: 'E70', name: "King's Indian Defense", name_ar: 'الدفاع الهندي الملكي'},
'd4 Nf6 c4 g6 Nc3 Bg7 e4 d6': {eco: 'E70', name: "King's Indian Defense", name_ar: 'الدفاع الهندي الملكي'},
'd4 Nf6 c4 g6 Nc3 Bg7 e4 d6 Nf3 O-O Be2 e5': {eco: 'E90', name: "King's Indian: Classical", name_ar: 'الهندي الملكي: الكلاسيكي'},
'd4 Nf6 c4 g6 Nc3 Bg7 e4 d6 f3': {eco: 'E81', name: "King's Indian: Saemisch", name_ar: 'الهندي الملكي: سيميش'},
'd4 Nf6 c4 g6 Nc3 Bg7 e4 d6 f4': {eco: 'E76', name: "King's Indian: Four Pawns Attack", name_ar: 'الهندي الملكي: هجوم الاربعة بيادق'},
// Nimzo-Indian
'd4 Nf6 c4 e6': {eco: 'E00', name: 'Indian Defense', name_ar: 'الدفاع الهندي'},
'd4 Nf6 c4 e6 Nc3 Bb4': {eco: 'E20', name: 'Nimzo-Indian Defense', name_ar: 'الدفاع الهندي نيمزو'},
'd4 Nf6 c4 e6 Nc3 Bb4 Qc2': {eco: 'E32', name: 'Nimzo-Indian: Classical', name_ar: 'نيمزو هندي: الكلاسيكي'},
'd4 Nf6 c4 e6 Nc3 Bb4 e3': {eco: 'E40', name: 'Nimzo-Indian: Rubinstein', name_ar: 'نيمزو هندي: روبنشتاين'},
// Grunfeld Defense
'd4 Nf6 c4 g6 Nc3 d5': {eco: 'D70', name: 'Grunfeld Defense', name_ar: 'دفاع غرونفيلد'},
'd4 Nf6 c4 g6 Nc3 d5 cxd5 Nxd5': {eco: 'D80', name: 'Grunfeld Defense: Exchange', name_ar: 'غرونفيلد: التبادل'},
'd4 Nf6 c4 g6 Nc3 d5 Nf3': {eco: 'D71', name: 'Grunfeld Defense', name_ar: 'دفاع غرونفيلد'},
// Catalan
'd4 Nf6 c4 e6 g3': {eco: 'E01', name: 'Catalan Opening', name_ar: 'الافتتاح الكتلاني'},
'd4 Nf6 c4 e6 g3 d5': {eco: 'E04', name: 'Catalan Opening', name_ar: 'الافتتاح الكتلاني'},
'd4 Nf6 c4 e6 g3 d5 Bg2': {eco: 'E04', name: 'Catalan Opening', name_ar: 'الافتتاح الكتلاني'},
// Queen's Indian
'd4 Nf6 c4 e6 Nf3 b6': {eco: 'E15', name: "Queen's Indian Defense", name_ar: 'الدفاع الهندي الملكة'},
// Bogo-Indian
'd4 Nf6 c4 e6 Nf3 Bb4+': {eco: 'E11', name: 'Bogo-Indian Defense', name_ar: 'الدفاع الهندي بوغو'},
// Dutch Defense
'd4 f5': {eco: 'A80', name: 'Dutch Defense', name_ar: 'الدفاع الهولندي'},
'd4 f5 c4 Nf6 g3 e6 Bg2': {eco: 'A83', name: 'Dutch Defense: Stonewall', name_ar: 'الدفاع الهولندي: الجدار الحجري'},
'd4 f5 g3': {eco: 'A81', name: 'Dutch Defense: Leningrad', name_ar: 'الدفاع الهولندي: لينينغراد'},
// === ENGLISH OPENING ===
'c4': {eco: 'A10', name: 'English Opening', name_ar: 'الافتتاح الانجليزي'},
'c4 e5': {eco: 'A20', name: 'English Opening: Reversed Sicilian', name_ar: 'الانجليزي: الصقلي المعكوس'},
'c4 c5': {eco: 'A30', name: 'English Opening: Symmetrical', name_ar: 'الانجليزي: المتماثل'},
'c4 Nf6': {eco: 'A15', name: 'English Opening', name_ar: 'الافتتاح الانجليزي'},
'c4 Nf6 Nc3 e5': {eco: 'A25', name: 'English Opening', name_ar: 'الافتتاح الانجليزي'},
'c4 Nf6 Nc3 g6': {eco: 'A16', name: 'English Opening', name_ar: 'الافتتاح الانجليزي'},
'c4 e6': {eco: 'A13', name: 'English Opening', name_ar: 'الافتتاح الانجليزي'},
// === RETI OPENING ===
'Nf3': {eco: 'A04', name: 'Reti Opening', name_ar: 'افتتاح ريتي'},
'Nf3 d5': {eco: 'A05', name: 'Reti Opening', name_ar: 'افتتاح ريتي'},
'Nf3 d5 c4': {eco: 'A09', name: 'Reti Opening', name_ar: 'افتتاح ريتي'},
'Nf3 Nf6 g3': {eco: 'A06', name: 'Reti Opening', name_ar: 'افتتاح ريتي'},
// === BIRD'S OPENING ===
'f4': {eco: 'A02', name: "Bird's Opening", name_ar: 'افتتاح بيرد'},
'f4 d5': {eco: 'A03', name: "Bird's Opening", name_ar: 'افتتاح بيرد'},
// === QUEEN'S PAWN MISC ===
'd4 d5 Nf3 Nf6 e3 e6 Bd3': {eco: 'D05', name: 'Colle System', name_ar: 'نظام كولي'},
'd4 d5 c4 e6 Nf3 Nf6 g3': {eco: 'D37', name: 'Catalan Variation', name_ar: 'تنويعة الكتلاني'},
// === MODERN / OTHER ===
'e4 g6': {eco: 'B06', name: 'Modern Defense', name_ar: 'الدفاع الحديث'},
'e4 g6 d4 Bg7': {eco: 'B06', name: 'Modern Defense', name_ar: 'الدفاع الحديث'},
'd4 g6': {eco: 'A40', name: 'Modern Defense', name_ar: 'الدفاع الحديث'},
// Benoni
'd4 Nf6 c4 c5 d5 e6': {eco: 'A60', name: 'Benoni Defense', name_ar: 'دفاع بينوني'},
'd4 Nf6 c4 c5 d5 e6 Nc3 exd5 cxd5 d6': {eco: 'A70', name: 'Benoni Defense: Classical', name_ar: 'بينوني: الكلاسيكي'},
// Budapest Gambit
'd4 Nf6 c4 e5': {eco: 'A51', name: 'Budapest Gambit', name_ar: 'غامبيت بودابست'},
// Trompowsky
'd4 Nf6 Bg5': {eco: 'A45', name: 'Trompowsky Attack', name_ar: 'هجوم ترومبوفسكي'},
// Torre Attack
'd4 Nf6 Nf3 e6 Bg5': {eco: 'A46', name: 'Torre Attack', name_ar: 'هجوم توري'},
// Zukertort
'Nf3 d5 d4 Nf6 c4 e6': {eco: 'D02', name: 'Zukertort Opening', name_ar: 'افتتاح زوكرتورت'}
};
// Sort keys by length (longest first) for prefix matching
const sortedKeys = Object.keys(db).sort((a, b) => b.length - a.length);
return {
/**
* Look up the opening for a given move sequence.
* @param {string} moves - Space-separated SAN moves (e.g. "e4 e5 Nf3 Nc6 Bb5")
* @returns {{eco: string, name: string, name_ar: string}|null}
*/
lookup: function(moves) {
if (!moves || moves.trim() === '') return null;
const normalized = moves.trim();
// Try exact match first
if (db[normalized]) return db[normalized];
// Find longest prefix match
for (let i = 0; i < sortedKeys.length; i++) {
const key = sortedKeys[i];
if (normalized === key || normalized.startsWith(key + ' ')) {
return db[key];
}
}
return null;
},
/**
* Get all openings in the database.
* @returns {Object}
*/
getAll: function() {
return db;
}
};
})();
// EL3AB Puzzle Controller - Daily puzzles, streak, themes, rush mode
const Puzzles = {
chess: null,
currentPuzzle: null,
puzzleQueue: [],
puzzleSolution: [],
solutionIndex: 0,
playerRating: 1200,
currentStreak: 0,
bestStreak: 0,
dailySolved: 0,
mode: 'daily', // daily, streak, themes, rush
selectedTheme: null,
rushTimer: null,
rushTimeLeft: 180, // 3 minutes
rushCount: 0,
isRushActive: false,
isSolving: false,
puzzleStartTime: 0,
async init() {
this.bindTabs();
await this.loadStats();
await this.loadDaily();
},
bindTabs() {
document.querySelectorAll('#puzzle-tabs .tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('#puzzle-tabs .tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
const tabId = tab.dataset.tab;
document.querySelectorAll('.puzzle-tab-content').forEach(el => el.style.display = 'none');
document.getElementById('tab-' + tabId).style.display = 'block';
this.mode = tabId;
// Hide board when switching tabs (unless already solving)
if (!this.isSolving) {
document.getElementById('puzzle-board-area').style.display = 'none';
}
});
});
// Theme cards
document.querySelectorAll('.puzzle-theme-card').forEach(card => {
card.addEventListener('click', () => {
this.selectedTheme = card.dataset.theme;
this.startThemePuzzles(this.selectedTheme);
});
});
},
async loadStats() {
try {
const data = await App.fetch('/api/puzzles?action=streak');
if (data) {
this.playerRating = data.puzzle_rating || 1200;
this.currentStreak = data.puzzle_streak || 0;
this.bestStreak = data.best_puzzle_streak || 0;
this.updateStatsUI();
}
} catch (e) {}
},
updateStatsUI() {
document.getElementById('puzzle-rating').textContent = this.playerRating;
document.getElementById('puzzle-streak').textContent = this.currentStreak;
document.getElementById('puzzle-best-streak').textContent = this.bestStreak;
},
async loadDaily() {
try {
const data = await App.fetch('/api/puzzles?action=daily');
if (data && data.puzzles && data.puzzles.length > 0) {
this.puzzleQueue = data.puzzles;
this.updateDailyProgress();
// Auto-start first unsolved daily puzzle
if (this.dailySolved < this.puzzleQueue.length) {
this.startPuzzle(this.puzzleQueue[this.dailySolved]);
}
} else {
// No puzzles from server, use built-in fallback puzzles
this.puzzleQueue = this.getFallbackPuzzles();
this.startPuzzle(this.puzzleQueue[0]);
}
} catch (e) {
this.puzzleQueue = this.getFallbackPuzzles();
this.startPuzzle(this.puzzleQueue[0]);
}
},
getFallbackPuzzles() {
// Built-in puzzles for offline/fallback
return [
{
id: 'local_1',
fen: 'r1bqkb1r/pppp1ppp/2n2n2/4p2Q/2B1P3/8/PPPP1PPP/RNB1K1NR w KQkq - 4 4',
moves: 'h5f7', // Scholar's mate
rating: 800,
themes: ['mate'],
title: 'مات بنقلة'
},
{
id: 'local_2',
fen: 'r1b1kb1r/pppp1ppp/5n2/4p1q1/2B1n3/2N2N2/PPPP1PPP/R1BQK2R w KQkq - 0 6',
moves: 'c3e4 g5g2',
rating: 1000,
themes: ['fork'],
title: 'شوكة'
},
{
id: 'local_3',
fen: '6k1/5ppp/8/8/8/8/r4PPP/4R1K1 w - - 0 1',
moves: 'e1e8',
rating: 900,
themes: ['endgame'],
title: 'تبادل قوي'
}
];
},
updateDailyProgress() {
const dots = document.querySelectorAll('.daily-dot');
dots.forEach((dot, i) => {
dot.classList.remove('solved', 'current');
if (i < this.dailySolved) {
dot.classList.add('solved');
dot.innerHTML = '<svg class="icon-sm" style="color:#fff"><use href="/public/icons/sprite.svg#icon-check"></use></svg>';
} else if (i === this.dailySolved) {
dot.classList.add('current');
}
});
},
startPuzzle(puzzle) {
if (!puzzle) return;
this.currentPuzzle = puzzle;
this.isSolving = true;
this.puzzleStartTime = Date.now();
// Parse solution moves
const movesStr = puzzle.moves || '';
this.puzzleSolution = movesStr.trim().split(/\s+/);
this.solutionIndex = 0;
// Setup chess position
this.chess = new Chess(puzzle.fen);
// Determine whose turn it is (the player needs to find the move for the side to move)
const playerSide = this.chess.turn();
// Show board area
document.getElementById('puzzle-board-area').style.display = 'block';
// Init board
Board.init('board', {
flipped: playerSide === 'b',
onMove: (move) => this.handleMove(move)
});
Board.setPosition(this.chess);
// Update puzzle info
document.getElementById('current-puzzle-rating').textContent = puzzle.rating || '?';
document.getElementById('puzzle-turn').textContent = playerSide === 'w' ? 'دور الابيض' : 'دور الاسود';
// Reset status
const statusEl = document.getElementById('puzzle-status');
statusEl.textContent = 'جد افضل نقلة';
statusEl.className = 'puzzle-status';
// Hide result
document.getElementById('puzzle-result').style.display = 'none';
// If puzzle starts with opponent's move first (multi-move puzzle)
// The first move in the solution is sometimes a "setup" move
// For simplicity, the solution moves are the player's required moves
},
handleMove(move) {
if (!this.isSolving || !this.currentPuzzle) return;
const expectedMove = this.puzzleSolution[this.solutionIndex];
if (!expectedMove) return;
// Convert player's move to UCI format
const uciMove = move.from + move.to + (move.promotion || '');
// Check if the move matches
if (uciMove === expectedMove || this.isEquivalentMove(move, expectedMove)) {
// Correct move
const result = this.chess.move({
from: move.from,
to: move.to,
promotion: move.promotion || 'q'
});
if (!result) return;
Board.lastMove = { from: move.from, to: move.to };
Board.setPosition(this.chess);
this.solutionIndex++;
// Check if puzzle is complete
if (this.solutionIndex >= this.puzzleSolution.length) {
this.puzzleSolved(true);
} else {
// Show "correct" status briefly, then make opponent's reply
const statusEl = document.getElementById('puzzle-status');
statusEl.textContent = 'صحيح! اكمل...';
statusEl.className = 'puzzle-status correct';
// Make the next move (opponent's reply) after a delay
setTimeout(() => {
this.makeOpponentMove();
}, 500);
}
} else {
// Wrong move
this.puzzleSolved(false);
}
},
isEquivalentMove(move, uciExpected) {
// Check if the move with default queen promotion matches
const from = uciExpected.substring(0, 2);
const to = uciExpected.substring(2, 4);
const promo = uciExpected.length > 4 ? uciExpected[4] : '';
if (move.from === from && move.to === to) {
if (!promo) return true;
if ((move.promotion || 'q') === promo) return true;
}
return false;
},
makeOpponentMove() {
const nextMove = this.puzzleSolution[this.solutionIndex];
if (!nextMove || nextMove.length < 4) return;
const from = nextMove.substring(0, 2);
const to = nextMove.substring(2, 4);
const promotion = nextMove.length > 4 ? nextMove[4] : undefined;
Board.animateMove(from, to, () => {
const result = this.chess.move({ from, to, promotion });
if (result) {
Board.lastMove = { from, to };
Board.setPosition(this.chess);
this.solutionIndex++;
const statusEl = document.getElementById('puzzle-status');
statusEl.textContent = 'دورك';
statusEl.className = 'puzzle-status';
}
});
},
puzzleSolved(correct) {
this.isSolving = false;
const timeMs = Date.now() - this.puzzleStartTime;
// Update streak
if (correct) {
this.currentStreak++;
if (this.currentStreak > this.bestStreak) {
this.bestStreak = this.currentStreak;
}
} else {
this.currentStreak = 0;
}
// Show result
const resultEl = document.getElementById('puzzle-result');
const iconEl = document.getElementById('puzzle-result-icon');
const textEl = document.getElementById('puzzle-result-text');
const statusEl = document.getElementById('puzzle-status');
if (correct) {
iconEl.innerHTML = '<svg class="icon-lg" style="color:var(--success)"><use href="/public/icons/sprite.svg#icon-check"></use></svg>';
textEl.textContent = 'احسنت! +' + Math.round(timeMs / 1000) + ' ثانية';
statusEl.textContent = 'صحيح!';
statusEl.className = 'puzzle-status correct';
} else {
iconEl.innerHTML = '<svg class="icon-lg" style="color:var(--error)"><use href="/public/icons/sprite.svg#icon-x"></use></svg>';
textEl.textContent = 'خطأ - الحل الصحيح: ' + this.formatSolution();
statusEl.textContent = 'خطأ';
statusEl.className = 'puzzle-status wrong';
// Show correct solution on board
this.showSolution();
}
resultEl.style.display = 'block';
this.updateStatsUI();
// Report attempt to server
this.reportAttempt(correct, timeMs);
// Handle rush mode
if (this.isRushActive) {
if (correct) {
this.rushCount++;
document.getElementById('rush-count').textContent = this.rushCount + ' حل';
} else {
this.endRush();
}
}
// Handle daily mode
if (this.mode === 'daily' && correct) {
this.dailySolved++;
this.updateDailyProgress();
}
},
formatSolution() {
return this.puzzleSolution.map(m => {
if (m.length >= 4) return m.substring(0, 2) + '-' + m.substring(2, 4);
return m;
}).join(', ');
},
showSolution() {
// Animate the correct first move
const correctMove = this.puzzleSolution[this.solutionIndex] || this.puzzleSolution[0];
if (!correctMove || correctMove.length < 4) return;
const from = correctMove.substring(0, 2);
const to = correctMove.substring(2, 4);
// Highlight the correct squares
const fromSq = Board.getSquareEl(from);
const toSq = Board.getSquareEl(to);
if (fromSq) fromSq.style.background = 'rgba(76,175,80,0.4)';
if (toSq) toSq.style.background = 'rgba(76,175,80,0.4)';
},
async reportAttempt(solved, timeMs) {
if (!this.currentPuzzle || this.currentPuzzle.id.startsWith('local_')) return;
try {
const res = await App.fetch('/api/puzzles', {
method: 'POST',
body: JSON.stringify({
action: 'attempt',
puzzle_id: this.currentPuzzle.id,
solved: solved,
time_ms: timeMs
})
});
if (res && res.rating_after) {
this.playerRating = res.rating_after;
this.currentStreak = res.streak;
this.bestStreak = res.best_streak;
this.updateStatsUI();
if (res.coins_awarded > 0) {
App.toast('+' + res.coins_awarded + ' عملة');
}
}
} catch (e) {}
},
nextPuzzle() {
// Get next puzzle from queue
const currentIndex = this.puzzleQueue.indexOf(this.currentPuzzle);
const nextIndex = currentIndex + 1;
if (nextIndex < this.puzzleQueue.length) {
this.startPuzzle(this.puzzleQueue[nextIndex]);
} else if (this.mode === 'streak' || this.isRushActive) {
// Load more puzzles
this.loadMorePuzzles();
} else {
// Daily complete or theme complete
document.getElementById('puzzle-board-area').style.display = 'none';
if (this.mode === 'daily') {
App.toast('اكملت الالغاز اليومية!', 'success');
} else {
App.toast('اكملت جميع الالغاز', 'success');
}
}
},
async loadMorePuzzles() {
let url = '/api/puzzles?rating=' + this.playerRating + '&limit=10';
if (this.selectedTheme) {
url += '&theme=' + this.selectedTheme;
}
try {
const data = await App.fetch(url);
if (data && data.puzzles && data.puzzles.length > 0) {
this.puzzleQueue = data.puzzles;
this.startPuzzle(this.puzzleQueue[0]);
} else {
// Use fallback
this.puzzleQueue = this.getFallbackPuzzles();
this.startPuzzle(this.puzzleQueue[0]);
}
} catch (e) {
this.puzzleQueue = this.getFallbackPuzzles();
this.startPuzzle(this.puzzleQueue[0]);
}
},
// Theme puzzles
async startThemePuzzles(theme) {
this.selectedTheme = theme;
this.mode = 'themes';
try {
const data = await App.fetch('/api/puzzles?theme=' + theme + '&rating=' + this.playerRating + '&limit=10');
if (data && data.puzzles && data.puzzles.length > 0) {
this.puzzleQueue = data.puzzles;
this.startPuzzle(this.puzzleQueue[0]);
} else {
this.puzzleQueue = this.getFallbackPuzzles();
this.startPuzzle(this.puzzleQueue[0]);
App.toast('لا توجد الغاز لهذا الموضوع حاليا');
}
} catch (e) {
this.puzzleQueue = this.getFallbackPuzzles();
this.startPuzzle(this.puzzleQueue[0]);
}
},
// Streak mode
async startStreak() {
this.mode = 'streak';
this.currentStreak = 0;
this.updateStatsUI();
await this.loadMorePuzzles();
},
// Rush mode
async startRush() {
this.isRushActive = true;
this.rushTimeLeft = 180;
this.rushCount = 0;
this.mode = 'rush';
document.getElementById('rush-timer').style.display = 'flex';
document.getElementById('btn-start-rush').style.display = 'none';
document.getElementById('rush-count').textContent = '0 حل';
// Start timer
this.rushTimer = setInterval(() => {
this.rushTimeLeft--;
this.updateRushTimer();
if (this.rushTimeLeft <= 0) {
this.endRush();
}
}, 1000);
// Load puzzles
await this.loadMorePuzzles();
},
updateRushTimer() {
const mins = Math.floor(this.rushTimeLeft / 60);
const secs = this.rushTimeLeft % 60;
document.getElementById('rush-time').textContent = mins + ':' + secs.toString().padStart(2, '0');
},
endRush() {
this.isRushActive = false;
if (this.rushTimer) {
clearInterval(this.rushTimer);
this.rushTimer = null;
}
// Show result
App.toast('انتهى السباق! حللت ' + this.rushCount + ' لغز', 'success');
document.getElementById('rush-timer').style.display = 'none';
document.getElementById('btn-start-rush').style.display = 'inline-flex';
document.getElementById('puzzle-board-area').style.display = 'none';
this.isSolving = false;
}
};
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
$navItems = [ $navItems = [
['/', 'icon-home', 'الرئيسية'], ['/', 'icon-home', 'الرئيسية'],
['/play', 'icon-play', 'العب'], ['/play', 'icon-play', 'العب'],
['/puzzles', 'icon-puzzle', 'تمارين'],
['/tournaments', 'icon-trophy', 'بطولات'], ['/tournaments', 'icon-trophy', 'بطولات'],
['/leaderboard', 'icon-leaderboard', 'متصدرون'], ['/leaderboard', 'icon-leaderboard', 'متصدرون'],
['/friends', 'icon-friends', 'اجتماعي'], ['/friends', 'icon-friends', 'اجتماعي'],
......
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