Commit 0aa09744 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: add full Ludo game + theme editor + CSS variable unification

- Ludo: 4-player support with CSS-only board (15x15 grid), bot AI
  (easy/hard), local pass-and-play, multiplayer via Supabase Realtime,
  in-game chat, matchmaking, private rooms, mixed human+bot games
- Theme editor: admin panel at /admin/theme with 9 categories,
  color pickers, file uploads, DB persistence, global cache
- CSS vars: unified all hardcoded colors across the codebase
  (chess board, analysis, bots, overlays, arrows, eval bar)
- New files: ludo-constants, ludo-ui, ludo-bot, ludo-game, ludo-live,
  ludo-chat, api/ludo, api/theme, pages/ludo*, admin-theme
- DB: ludo_matches + ludo_queue tables with RLS policies
- Nav: added Ludo icon to sprite.svg and both nav menus
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 9924c814
......@@ -15,3 +15,8 @@ Connections and docs /
# Deps (none for now, but future-proofing)
vendor/
node_modules/
# Theme cache & uploads
storage/theme-cache.json
public/uploads/theme/*
!public/uploads/theme/.gitkeep
......@@ -29,3 +29,7 @@ RewriteRule ^(.*)$ index.php?route=$1 [QSA,L]
<Files "config/*">
Require all denied
</Files>
<Files "storage/*">
Require all denied
</Files>
<?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;
}
$userRes = supabase_auth('user', [], $token, 'GET');
$userId = $userRes['data']['id'] ?? null;
$userName = $userRes['data']['user_metadata']['display_name'] ?? 'لاعب';
if (!$userId) {
http_response_code(401);
echo json_encode(['error' => 'invalid_token']);
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$action = $_GET['action'] ?? '';
handleGet($action, $userId, $token);
} elseif ($_SERVER['REQUEST_METHOD'] === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
$action = $input['action'] ?? '';
handlePost($action, $input, $userId, $userName, $token);
} else {
http_response_code(405);
echo json_encode(['error' => 'method_not_allowed']);
}
function handleGet($action, $userId, $token) {
switch ($action) {
case 'status':
$matchId = $_GET['match_id'] ?? '';
if (!$matchId) {
http_response_code(400);
echo json_encode(['error' => 'match_id_required']);
return;
}
$res = supabase_rest('GET', "ludo_matches?id=eq.{$matchId}&select=*", [], $token);
if (empty($res['data'])) {
http_response_code(404);
echo json_encode(['error' => 'not_found']);
return;
}
echo json_encode(['ok' => true, 'match' => $res['data'][0]]);
break;
default:
http_response_code(400);
echo json_encode(['error' => 'invalid_action']);
}
}
function handlePost($action, $input, $userId, $userName, $token) {
switch ($action) {
case 'create':
createRoom($input, $userId, $userName, $token);
break;
case 'join':
joinRoom($input, $userId, $userName, $token);
break;
case 'start':
startGame($input, $userId, $token);
break;
case 'roll':
rollDice($input, $userId, $token);
break;
case 'move':
movePiece($input, $userId, $token);
break;
case 'leave':
leaveGame($input, $userId, $token);
break;
case 'chat':
sendChat($input, $userId, $userName, $token);
break;
case 'matchmake':
matchmake($input, $userId, $userName, $token);
break;
default:
http_response_code(400);
echo json_encode(['error' => 'invalid_action']);
}
}
function generateRoomCode() {
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$code = '';
for ($i = 0; $i < 6; $i++) {
$code .= $chars[random_int(0, strlen($chars) - 1)];
}
return $code;
}
function createRoom($input, $userId, $userName, $token) {
$playerCount = intval($input['player_count'] ?? 4);
if ($playerCount < 2) $playerCount = 2;
if ($playerCount > 4) $playerCount = 4;
$bots = $input['bots'] ?? [];
$roomCode = generateRoomCode();
$players = [
['id' => $userId, 'name' => $userName, 'color' => 'P1', 'type' => 'human', 'connected' => true]
];
foreach ($bots as $bot) {
$colorIdx = count($players);
$colors = ['P1', 'P2', 'P3', 'P4'];
if ($colorIdx >= $playerCount) break;
$players[] = [
'id' => 'bot_' . $colorIdx,
'name' => $bot['name'] ?? ('بوت ' . $colorIdx),
'color' => $colors[$colorIdx],
'type' => 'bot',
'difficulty' => $bot['difficulty'] ?? 'easy',
'connected' => true
];
}
$positions = [];
$basePositions = ['P1' => [500,501,502,503], 'P2' => [600,601,602,603], 'P3' => [700,701,702,703], 'P4' => [800,801,802,803]];
$colors = ['P1','P2','P3','P4'];
for ($i = 0; $i < $playerCount; $i++) {
$positions[$colors[$i]] = $basePositions[$colors[$i]];
}
$data = [
'room_code' => $roomCode,
'status' => 'waiting',
'player_count' => $playerCount,
'players' => json_encode($players),
'current_turn' => 0,
'positions' => json_encode($positions),
'moves' => json_encode([]),
'winners' => json_encode([]),
'game_state' => json_encode(['phase' => 'waiting']),
'chat' => json_encode([]),
'host_id' => $userId
];
$res = supabase_rest('POST', 'ludo_matches', $data, $token);
if ($res['status'] >= 400) {
http_response_code(500);
echo json_encode(['error' => 'create_failed', 'details' => $res['data']]);
return;
}
echo json_encode(['ok' => true, 'match' => $res['data'][0], 'room_code' => $roomCode]);
}
function joinRoom($input, $userId, $userName, $token) {
$roomCode = strtoupper(trim($input['room_code'] ?? ''));
if (!$roomCode) {
http_response_code(400);
echo json_encode(['error' => 'room_code_required']);
return;
}
$res = supabase_rest('GET', "ludo_matches?room_code=eq.{$roomCode}&status=eq.waiting&select=*", [], $token);
if (empty($res['data'])) {
http_response_code(404);
echo json_encode(['error' => 'room_not_found']);
return;
}
$match = $res['data'][0];
$players = json_decode($match['players'], true);
foreach ($players as $p) {
if ($p['id'] === $userId) {
echo json_encode(['ok' => true, 'match' => $match, 'already_joined' => true]);
return;
}
}
if (count($players) >= $match['player_count']) {
http_response_code(400);
echo json_encode(['error' => 'room_full']);
return;
}
$colors = ['P1','P2','P3','P4'];
$usedColors = array_map(function($p) { return $p['color']; }, $players);
$nextColor = '';
foreach ($colors as $c) {
if (!in_array($c, $usedColors)) { $nextColor = $c; break; }
}
$players[] = [
'id' => $userId,
'name' => $userName,
'color' => $nextColor,
'type' => 'human',
'connected' => true
];
$update = ['players' => json_encode($players)];
$patchRes = supabase_rest('PATCH', "ludo_matches?id=eq.{$match['id']}", $update, $token);
if ($patchRes['status'] >= 400) {
http_response_code(500);
echo json_encode(['error' => 'join_failed']);
return;
}
echo json_encode(['ok' => true, 'match' => $patchRes['data'][0]]);
}
function startGame($input, $userId, $token) {
$matchId = $input['match_id'] ?? '';
if (!$matchId) {
http_response_code(400);
echo json_encode(['error' => 'match_id_required']);
return;
}
$res = supabase_rest('GET', "ludo_matches?id=eq.{$matchId}&select=*", [], $token);
if (empty($res['data'])) {
http_response_code(404);
echo json_encode(['error' => 'not_found']);
return;
}
$match = $res['data'][0];
if ($match['host_id'] !== $userId) {
http_response_code(403);
echo json_encode(['error' => 'not_host']);
return;
}
if ($match['status'] !== 'waiting') {
http_response_code(400);
echo json_encode(['error' => 'already_started']);
return;
}
$players = json_decode($match['players'], true);
// Fill remaining slots with bots
$colors = ['P1','P2','P3','P4'];
$usedColors = array_map(function($p) { return $p['color']; }, $players);
$botIdx = count($players);
while (count($players) < $match['player_count']) {
$nextColor = '';
foreach ($colors as $c) {
if (!in_array($c, $usedColors)) { $nextColor = $c; break; }
}
$usedColors[] = $nextColor;
$players[] = [
'id' => 'bot_' . $botIdx,
'name' => 'بوت ' . $botIdx,
'color' => $nextColor,
'type' => 'bot',
'difficulty' => 'easy',
'connected' => true
];
$botIdx++;
}
$update = [
'status' => 'in_progress',
'players' => json_encode($players),
'game_state' => json_encode(['phase' => 'dice_not_rolled', 'started_at' => date('c')])
];
$patchRes = supabase_rest('PATCH', "ludo_matches?id=eq.{$matchId}", $update, $token);
echo json_encode(['ok' => true, 'match' => $patchRes['data'][0]]);
}
function rollDice($input, $userId, $token) {
$matchId = $input['match_id'] ?? '';
$match = getAndValidateMatch($matchId, $userId, $token);
if (!$match) return;
$players = json_decode($match['players'], true);
$currentTurn = $match['current_turn'];
$currentPlayer = $players[$currentTurn];
if ($currentPlayer['id'] !== $userId) {
http_response_code(400);
echo json_encode(['error' => 'not_your_turn']);
return;
}
$gameState = json_decode($match['game_state'], true);
if (($gameState['phase'] ?? '') !== 'dice_not_rolled') {
http_response_code(400);
echo json_encode(['error' => 'already_rolled']);
return;
}
$diceValue = random_int(1, 6);
$gameState['phase'] = 'dice_rolled';
$gameState['last_dice'] = $diceValue;
$gameState['last_roll_at'] = date('c');
$update = [
'dice_value' => $diceValue,
'game_state' => json_encode($gameState)
];
$patchRes = supabase_rest('PATCH', "ludo_matches?id=eq.{$matchId}", $update, $token);
echo json_encode(['ok' => true, 'dice_value' => $diceValue, 'match' => $patchRes['data'][0]]);
}
function movePiece($input, $userId, $token) {
$matchId = $input['match_id'] ?? '';
$pieceIdx = intval($input['piece'] ?? -1);
$match = getAndValidateMatch($matchId, $userId, $token);
if (!$match) return;
$players = json_decode($match['players'], true);
$currentTurn = $match['current_turn'];
$currentPlayer = $players[$currentTurn];
if ($currentPlayer['id'] !== $userId) {
http_response_code(400);
echo json_encode(['error' => 'not_your_turn']);
return;
}
$gameState = json_decode($match['game_state'], true);
if (($gameState['phase'] ?? '') !== 'dice_rolled') {
http_response_code(400);
echo json_encode(['error' => 'dice_not_rolled']);
return;
}
$positions = json_decode($match['positions'], true);
$moves = json_decode($match['moves'], true);
$winners = json_decode($match['winners'], true);
$diceValue = $match['dice_value'];
$playerColor = $currentPlayer['color'];
$basePositions = ['P1' => [500,501,502,503], 'P2' => [600,601,602,603], 'P3' => [700,701,702,703], 'P4' => [800,801,802,803]];
$startPositions = ['P1' => 0, 'P2' => 13, 'P3' => 26, 'P4' => 39];
$turningPoints = ['P1' => 50, 'P2' => 11, 'P3' => 24, 'P4' => 37];
$homeEntrance = ['P1' => [100,101,102,103,104], 'P2' => [200,201,202,203,204], 'P3' => [300,301,302,303,304], 'P4' => [400,401,402,403,404]];
$homePositions = ['P1' => 105, 'P2' => 205, 'P3' => 305, 'P4' => 405];
$safePositions = [0, 8, 13, 21, 26, 34, 39, 47];
if ($pieceIdx < 0 || $pieceIdx > 3) {
http_response_code(400);
echo json_encode(['error' => 'invalid_piece']);
return;
}
$currentPos = $positions[$playerColor][$pieceIdx];
$isInBase = in_array($currentPos, $basePositions[$playerColor]);
$newPos = null;
if ($isInBase) {
if ($diceValue !== 6) {
http_response_code(400);
echo json_encode(['error' => 'need_six_to_exit']);
return;
}
$newPos = $startPositions[$playerColor];
} else {
$newPos = calculateNewPos($playerColor, $currentPos, $diceValue, $turningPoints, $homeEntrance, $homePositions);
}
if ($newPos === null) {
http_response_code(400);
echo json_encode(['error' => 'invalid_move']);
return;
}
$positions[$playerColor][$pieceIdx] = $newPos;
$killed = null;
// Check kills
if ($newPos >= 0 && $newPos <= 51 && !in_array($newPos, $safePositions)) {
$activePlayers = array_map(function($p) { return $p['color']; }, $players);
foreach ($activePlayers as $oppColor) {
if ($oppColor === $playerColor) continue;
if (!isset($positions[$oppColor])) continue;
for ($j = 0; $j < 4; $j++) {
if ($positions[$oppColor][$j] === $newPos) {
$positions[$oppColor][$j] = $basePositions[$oppColor][$j];
$killed = ['player' => $oppColor, 'piece' => $j];
break 2;
}
}
}
}
$moveRecord = [
'player' => $playerColor,
'piece' => $pieceIdx,
'from' => $currentPos,
'to' => $newPos,
'dice' => $diceValue,
'kill' => $killed,
'at' => date('c')
];
$moves[] = $moveRecord;
// Check if player finished
$reachedHome = ($newPos === $homePositions[$playerColor]);
$playerFinished = true;
for ($i = 0; $i < 4; $i++) {
if ($positions[$playerColor][$i] !== $homePositions[$playerColor]) {
$playerFinished = false;
break;
}
}
if ($playerFinished && !in_array($playerColor, $winners)) {
$winners[] = $playerColor;
}
// Determine next turn
$extraTurn = ($diceValue === 6 || $killed !== null || $reachedHome) && !$playerFinished;
$consecutiveSixes = intval($gameState['consecutive_sixes'] ?? 0);
if ($diceValue === 6) {
$consecutiveSixes++;
if ($consecutiveSixes >= 3) {
$extraTurn = false;
$consecutiveSixes = 0;
}
} else {
$consecutiveSixes = 0;
}
$activePlayers = array_map(function($p) { return $p['color']; }, $players);
$remaining = array_filter($activePlayers, function($c) use ($winners) { return !in_array($c, $winners); });
$gameOver = count($remaining) <= 1;
$nextTurn = $currentTurn;
if (!$extraTurn || $playerFinished) {
$consecutiveSixes = 0;
$totalPlayers = count($players);
$nextTurn = ($currentTurn + 1) % $totalPlayers;
$attempts = 0;
while (in_array($players[$nextTurn]['color'], $winners) && $attempts < $totalPlayers) {
$nextTurn = ($nextTurn + 1) % $totalPlayers;
$attempts++;
}
}
$gameState['phase'] = 'dice_not_rolled';
$gameState['consecutive_sixes'] = $consecutiveSixes;
$gameState['last_move_at'] = date('c');
$update = [
'positions' => json_encode($positions),
'moves' => json_encode($moves),
'winners' => json_encode($winners),
'current_turn' => $nextTurn,
'game_state' => json_encode($gameState)
];
if ($gameOver) {
foreach ($remaining as $c) {
if (!in_array($c, $winners)) $winners[] = $c;
}
$update['winners'] = json_encode($winners);
$update['status'] = 'completed';
$update['completed_at'] = date('c');
$update['result'] = $winners[0] . '_wins';
$gameState['phase'] = 'game_over';
$update['game_state'] = json_encode($gameState);
}
$patchRes = supabase_rest('PATCH', "ludo_matches?id=eq.{$matchId}", $update, $token);
$response = ['ok' => true, 'match' => $patchRes['data'][0], 'move' => $moveRecord];
if ($killed) $response['killed'] = $killed;
if ($gameOver) $response['game_over'] = true;
echo json_encode($response);
// Execute bot turns if next player is a bot
if (!$gameOver && $players[$nextTurn]['type'] === 'bot') {
executeBotTurns($matchId, $patchRes['data'][0], $token);
}
}
function executeBotTurns($matchId, $match, $token) {
$maxIterations = 20;
$iteration = 0;
while ($iteration < $maxIterations) {
$iteration++;
$players = json_decode($match['players'], true);
$currentTurn = $match['current_turn'];
$currentPlayer = $players[$currentTurn];
if ($currentPlayer['type'] !== 'bot') break;
if ($match['status'] === 'completed') break;
$positions = json_decode($match['positions'], true);
$gameState = json_decode($match['game_state'], true);
$moves = json_decode($match['moves'], true);
$winners = json_decode($match['winners'], true);
$playerColor = $currentPlayer['color'];
$basePositions = ['P1' => [500,501,502,503], 'P2' => [600,601,602,603], 'P3' => [700,701,702,703], 'P4' => [800,801,802,803]];
$startPositions = ['P1' => 0, 'P2' => 13, 'P3' => 26, 'P4' => 39];
$turningPoints = ['P1' => 50, 'P2' => 11, 'P3' => 24, 'P4' => 37];
$homeEntrance = ['P1' => [100,101,102,103,104], 'P2' => [200,201,202,203,204], 'P3' => [300,301,302,303,304], 'P4' => [400,401,402,403,404]];
$homePositions = ['P1' => 105, 'P2' => 205, 'P3' => 305, 'P4' => 405];
$safePositions = [0, 8, 13, 21, 26, 34, 39, 47];
// Bot rolls dice
$diceValue = random_int(1, 6);
// Find eligible pieces
$eligible = [];
for ($i = 0; $i < 4; $i++) {
$pos = $positions[$playerColor][$i];
if ($pos === $homePositions[$playerColor]) continue;
$isInBase = in_array($pos, $basePositions[$playerColor]);
if ($isInBase) {
if ($diceValue === 6) $eligible[] = $i;
continue;
}
$newPos = calculateNewPos($playerColor, $pos, $diceValue, $turningPoints, $homeEntrance, $homePositions);
if ($newPos !== null) $eligible[] = $i;
}
if (empty($eligible)) {
// No moves, skip turn
$consecutiveSixes = 0;
$nextTurn = findNextTurn($currentTurn, $players, $winners);
$gameState['phase'] = 'dice_not_rolled';
$gameState['consecutive_sixes'] = 0;
$update = [
'current_turn' => $nextTurn,
'dice_value' => $diceValue,
'game_state' => json_encode($gameState)
];
$patchRes = supabase_rest('PATCH', "ludo_matches?id=eq.{$matchId}", $update, $token);
$match = $patchRes['data'][0];
continue;
}
// Bot picks piece (simple: random for easy, best for hard)
$difficulty = $currentPlayer['difficulty'] ?? 'easy';
$chosenPiece = $eligible[0];
if ($difficulty === 'easy') {
$chosenPiece = $eligible[array_rand($eligible)];
} else {
$bestScore = -999;
foreach ($eligible as $pi) {
$score = scoreBotMove($playerColor, $pi, $diceValue, $positions, $players, $basePositions, $startPositions, $homeEntrance, $homePositions, $safePositions);
if ($score > $bestScore) {
$bestScore = $score;
$chosenPiece = $pi;
}
}
}
// Execute move
$currentPos = $positions[$playerColor][$chosenPiece];
$isInBase = in_array($currentPos, $basePositions[$playerColor]);
$newPos = $isInBase ? $startPositions[$playerColor] : calculateNewPos($playerColor, $currentPos, $diceValue, $turningPoints, $homeEntrance, $homePositions);
$positions[$playerColor][$chosenPiece] = $newPos;
$killed = null;
if ($newPos >= 0 && $newPos <= 51 && !in_array($newPos, $safePositions)) {
foreach ($players as $p) {
if ($p['color'] === $playerColor) continue;
$oppColor = $p['color'];
if (!isset($positions[$oppColor])) continue;
for ($j = 0; $j < 4; $j++) {
if ($positions[$oppColor][$j] === $newPos) {
$positions[$oppColor][$j] = $basePositions[$oppColor][$j];
$killed = ['player' => $oppColor, 'piece' => $j];
break 2;
}
}
}
}
$moves[] = [
'player' => $playerColor,
'piece' => $chosenPiece,
'from' => $currentPos,
'to' => $newPos,
'dice' => $diceValue,
'kill' => $killed,
'at' => date('c'),
'bot' => true
];
$reachedHome = ($newPos === $homePositions[$playerColor]);
$playerFinished = true;
for ($i = 0; $i < 4; $i++) {
if ($positions[$playerColor][$i] !== $homePositions[$playerColor]) {
$playerFinished = false;
break;
}
}
if ($playerFinished && !in_array($playerColor, $winners)) {
$winners[] = $playerColor;
}
$extraTurn = ($diceValue === 6 || $killed !== null || $reachedHome) && !$playerFinished;
$consecutiveSixes = intval($gameState['consecutive_sixes'] ?? 0);
if ($diceValue === 6) {
$consecutiveSixes++;
if ($consecutiveSixes >= 3) { $extraTurn = false; $consecutiveSixes = 0; }
} else {
$consecutiveSixes = 0;
}
$activePlayers = array_map(function($p) { return $p['color']; }, $players);
$remaining = array_filter($activePlayers, function($c) use ($winners) { return !in_array($c, $winners); });
$gameOver = count($remaining) <= 1;
$nextTurn = $currentTurn;
if (!$extraTurn || $playerFinished) {
$consecutiveSixes = 0;
$nextTurn = findNextTurn($currentTurn, $players, $winners);
}
$gameState['phase'] = 'dice_not_rolled';
$gameState['consecutive_sixes'] = $consecutiveSixes;
$gameState['last_move_at'] = date('c');
$update = [
'positions' => json_encode($positions),
'moves' => json_encode($moves),
'winners' => json_encode($winners),
'current_turn' => $nextTurn,
'dice_value' => $diceValue,
'game_state' => json_encode($gameState)
];
if ($gameOver) {
foreach ($remaining as $c) {
if (!in_array($c, $winners)) $winners[] = $c;
}
$update['winners'] = json_encode($winners);
$update['status'] = 'completed';
$update['completed_at'] = date('c');
$update['result'] = $winners[0] . '_wins';
$gameState['phase'] = 'game_over';
$update['game_state'] = json_encode($gameState);
}
$patchRes = supabase_rest('PATCH', "ludo_matches?id=eq.{$matchId}", $update, $token);
$match = $patchRes['data'][0];
if ($gameOver) break;
}
}
function findNextTurn($currentTurn, $players, $winners) {
$total = count($players);
$next = ($currentTurn + 1) % $total;
$attempts = 0;
while (in_array($players[$next]['color'], $winners) && $attempts < $total) {
$next = ($next + 1) % $total;
$attempts++;
}
return $next;
}
function scoreBotMove($playerColor, $pieceIdx, $diceValue, $positions, $players, $basePositions, $startPositions, $homeEntrance, $homePositions, $safePositions) {
$score = 0;
$currentPos = $positions[$playerColor][$pieceIdx];
$isInBase = in_array($currentPos, $basePositions[$playerColor]);
if ($isInBase) {
$score += 30;
$startPos = $startPositions[$playerColor];
foreach ($players as $p) {
if ($p['color'] === $playerColor) continue;
if (!isset($positions[$p['color']])) continue;
for ($j = 0; $j < 4; $j++) {
if ($positions[$p['color']][$j] === $startPos) { $score += 50; break 2; }
}
}
return $score;
}
$turningPoints = ['P1' => 50, 'P2' => 11, 'P3' => 24, 'P4' => 37];
$newPos = calculateNewPos($playerColor, $currentPos, $diceValue, $turningPoints, $homeEntrance, $homePositions);
if ($newPos === null) return -999;
if ($newPos === $homePositions[$playerColor]) return 100;
if (in_array($newPos, $homeEntrance[$playerColor])) $score += 25;
if ($newPos >= 0 && $newPos <= 51) {
foreach ($players as $p) {
if ($p['color'] === $playerColor) continue;
if (!isset($positions[$p['color']])) continue;
for ($j = 0; $j < 4; $j++) {
if ($positions[$p['color']][$j] === $newPos && !in_array($newPos, $safePositions)) {
$score += 50;
break 2;
}
}
}
if (in_array($newPos, $safePositions)) $score += 20;
}
return $score;
}
function calculateNewPos($playerColor, $currentPos, $diceValue, $turningPoints, $homeEntrance, $homePositions) {
$isInHome = in_array($currentPos, $homeEntrance[$playerColor]);
if ($isInHome) {
$homeIdx = array_search($currentPos, $homeEntrance[$playerColor]);
$stepsLeft = 5 - $homeIdx;
if ($diceValue > $stepsLeft) return null;
if ($diceValue === $stepsLeft) return $homePositions[$playerColor];
return $homeEntrance[$playerColor][$homeIdx + $diceValue];
}
$turningPoint = $turningPoints[$playerColor];
$distanceToTurn = ($turningPoint - $currentPos + 52) % 52;
if ($distanceToTurn === 0) {
if ($diceValue <= 5) return $homeEntrance[$playerColor][$diceValue - 1];
return null;
}
if ($diceValue > $distanceToTurn && $distanceToTurn > 0 && $distanceToTurn < 7) {
$overflow = $diceValue - $distanceToTurn;
if ($overflow <= 5) return $homeEntrance[$playerColor][$overflow - 1];
return null;
}
return ($currentPos + $diceValue) % 52;
}
function leaveGame($input, $userId, $token) {
$matchId = $input['match_id'] ?? '';
$match = getAndValidateMatch($matchId, $userId, $token);
if (!$match) return;
$players = json_decode($match['players'], true);
$playerIdx = -1;
foreach ($players as $idx => $p) {
if ($p['id'] === $userId) { $playerIdx = $idx; break; }
}
if ($playerIdx === -1) {
http_response_code(400);
echo json_encode(['error' => 'not_in_match']);
return;
}
// Replace player with bot
$players[$playerIdx]['type'] = 'bot';
$players[$playerIdx]['difficulty'] = 'easy';
$players[$playerIdx]['connected'] = false;
$players[$playerIdx]['name'] = 'بوت (غادر)';
$update = ['players' => json_encode($players)];
$patchRes = supabase_rest('PATCH', "ludo_matches?id=eq.{$matchId}", $update, $token);
echo json_encode(['ok' => true]);
}
function sendChat($input, $userId, $userName, $token) {
$matchId = $input['match_id'] ?? '';
$text = trim($input['text'] ?? '');
if (!$text || strlen($text) > 100) {
http_response_code(400);
echo json_encode(['error' => 'invalid_message']);
return;
}
$match = getAndValidateMatch($matchId, $userId, $token);
if (!$match) return;
$chat = json_decode($match['chat'], true);
$chat[] = [
'sender_id' => $userId,
'sender_name' => $userName,
'text' => htmlspecialchars($text, ENT_QUOTES, 'UTF-8'),
'at' => date('c')
];
$update = ['chat' => json_encode($chat)];
$patchRes = supabase_rest('PATCH', "ludo_matches?id=eq.{$matchId}", $update, $token);
echo json_encode(['ok' => true]);
}
function matchmake($input, $userId, $userName, $token) {
$subAction = $input['sub_action'] ?? 'join';
if ($subAction === 'leave') {
supabase_rest('DELETE', "ludo_queue?user_id=eq.{$userId}", [], $token);
echo json_encode(['ok' => true, 'status' => 'left']);
return;
}
// Check if already in queue
$existing = supabase_rest('GET', "ludo_queue?user_id=eq.{$userId}&select=*", [], $token);
if (!empty($existing['data'])) {
// Poll for match
$queueEntry = $existing['data'][0];
if (!empty($queueEntry['match_id'])) {
echo json_encode(['ok' => true, 'status' => 'matched', 'match_id' => $queueEntry['match_id']]);
supabase_rest('DELETE', "ludo_queue?user_id=eq.{$userId}", [], $token);
return;
}
// Try to find opponent
$opponents = supabase_rest('GET', "ludo_queue?user_id=neq.{$userId}&match_id=is.null&select=*&limit=1", [], $token);
if (!empty($opponents['data'])) {
$opponent = $opponents['data'][0];
// Create match
$roomCode = generateRoomCode();
$matchData = [
'room_code' => $roomCode,
'status' => 'in_progress',
'player_count' => 2,
'players' => json_encode([
['id' => $userId, 'name' => $userName, 'color' => 'P1', 'type' => 'human', 'connected' => true],
['id' => $opponent['user_id'], 'name' => $opponent['user_name'] ?? 'لاعب', 'color' => 'P2', 'type' => 'human', 'connected' => true]
]),
'current_turn' => 0,
'positions' => json_encode(['P1' => [500,501,502,503], 'P2' => [600,601,602,603]]),
'moves' => json_encode([]),
'winners' => json_encode([]),
'game_state' => json_encode(['phase' => 'dice_not_rolled', 'started_at' => date('c')]),
'chat' => json_encode([]),
'host_id' => $userId
];
$matchRes = supabase_rest('POST', 'ludo_matches', $matchData, $token);
if (!empty($matchRes['data'])) {
$newMatchId = $matchRes['data'][0]['id'];
supabase_rest('PATCH', "ludo_queue?user_id=eq.{$opponent['user_id']}", ['match_id' => $newMatchId], $token);
supabase_rest('DELETE', "ludo_queue?user_id=eq.{$userId}", [], $token);
echo json_encode(['ok' => true, 'status' => 'matched', 'match_id' => $newMatchId]);
return;
}
}
echo json_encode(['ok' => true, 'status' => 'waiting']);
return;
}
// Join queue
$queueData = [
'user_id' => $userId,
'user_name' => $userName
];
supabase_rest('POST', 'ludo_queue', $queueData, $token);
echo json_encode(['ok' => true, 'status' => 'queued']);
}
function getAndValidateMatch($matchId, $userId, $token) {
if (!$matchId) {
http_response_code(400);
echo json_encode(['error' => 'match_id_required']);
return null;
}
$res = supabase_rest('GET', "ludo_matches?id=eq.{$matchId}&select=*", [], $token);
if (empty($res['data'])) {
http_response_code(404);
echo json_encode(['error' => 'not_found']);
return null;
}
$match = $res['data'][0];
if ($match['status'] === 'completed' || $match['status'] === 'aborted') {
http_response_code(400);
echo json_encode(['error' => 'game_ended']);
return null;
}
$players = json_decode($match['players'], true);
$found = false;
foreach ($players as $p) {
if ($p['id'] === $userId) { $found = true; break; }
}
if (!$found) {
http_response_code(403);
echo json_encode(['error' => 'not_in_match']);
return null;
}
return $match;
}
<?php
require_once __DIR__ . '/../config/database.php';
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'method not allowed']);
exit;
}
$adminUser = $_POST['admin_user'] ?? '';
$adminPass = $_POST['admin_pass'] ?? '';
if ($adminUser !== 'admin' || $adminPass !== 'Alarcade123#') {
http_response_code(403);
echo json_encode(['error' => 'forbidden']);
exit;
}
if (empty($_FILES['file'])) {
http_response_code(400);
echo json_encode(['error' => 'no file uploaded']);
exit;
}
$file = $_FILES['file'];
$key = $_POST['key'] ?? '';
$category = $_POST['category'] ?? 'assets';
if (!$key) {
http_response_code(400);
echo json_encode(['error' => 'key is required']);
exit;
}
$allowed = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp', 'image/gif'];
if (!in_array($file['type'], $allowed)) {
http_response_code(400);
echo json_encode(['error' => 'invalid file type']);
exit;
}
$maxSize = 2 * 1024 * 1024;
if ($file['size'] > $maxSize) {
http_response_code(400);
echo json_encode(['error' => 'file too large (max 2MB)']);
exit;
}
$uploadDir = __DIR__ . '/../public/uploads/theme/';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
$safeKey = preg_replace('/[^a-zA-Z0-9_-]/', '_', $key);
$filename = $safeKey . '.' . $ext;
$destPath = $uploadDir . $filename;
if (!move_uploaded_file($file['tmp_name'], $destPath)) {
http_response_code(500);
echo json_encode(['error' => 'upload failed']);
exit;
}
$publicUrl = '/public/uploads/theme/' . $filename;
$existing = supabase_rest('GET', 'theme_settings?key=eq.' . urlencode($key) . '&select=id', [], SUPABASE_SERVICE_KEY);
if (!empty($existing['data']) && is_array($existing['data']) && !isset($existing['data']['code'])) {
supabase_rest('PATCH', 'theme_settings?key=eq.' . urlencode($key), [
'value' => $publicUrl,
'category' => $category,
'label' => $_POST['label'] ?? $key,
'updated_at' => date('c'),
], SUPABASE_SERVICE_KEY);
} else {
supabase_rest('POST', 'theme_settings', [
'key' => $key,
'value' => $publicUrl,
'category' => $category,
'label' => $_POST['label'] ?? $key,
], SUPABASE_SERVICE_KEY);
}
echo json_encode(['ok' => true, 'url' => $publicUrl]);
<?php
require_once __DIR__ . '/../config/database.php';
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
$res = supabase_rest('GET', 'theme_settings?select=key,value,category', [], SUPABASE_SERVICE_KEY);
$settings = ($res['status'] === 200 && is_array($res['data']) && !isset($res['data']['code'])) ? $res['data'] : [];
echo json_encode(['settings' => $settings]);
exit;
}
if ($method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
$adminUser = $input['admin_user'] ?? '';
$adminPass = $input['admin_pass'] ?? '';
if ($adminUser !== 'admin' || $adminPass !== 'Alarcade123#') {
http_response_code(403);
echo json_encode(['error' => 'forbidden']);
exit;
}
$action = $input['action'] ?? '';
if ($action === 'save') {
$settings = $input['settings'] ?? [];
$errors = [];
foreach ($settings as $item) {
$key = $item['key'] ?? '';
$value = $item['value'] ?? '';
$category = $item['category'] ?? 'colors';
$label = $item['label'] ?? $key;
if (!$key || !$value) continue;
$existing = supabase_rest('GET', 'theme_settings?key=eq.' . urlencode($key) . '&select=id', [], SUPABASE_SERVICE_KEY);
if (!empty($existing['data']) && is_array($existing['data']) && !isset($existing['data']['code'])) {
$res = supabase_rest('PATCH', 'theme_settings?key=eq.' . urlencode($key), [
'value' => $value,
'category' => $category,
'label' => $label,
'updated_at' => date('c'),
], SUPABASE_SERVICE_KEY);
} else {
$res = supabase_rest('POST', 'theme_settings', [
'key' => $key,
'value' => $value,
'category' => $category,
'label' => $label,
], SUPABASE_SERVICE_KEY);
}
if ($res['status'] >= 400) {
$errors[] = $key;
}
}
echo json_encode(['ok' => true, 'errors' => $errors]);
} elseif ($action === 'delete') {
$key = $input['key'] ?? '';
if ($key) {
supabase_rest('DELETE', 'theme_settings?key=eq.' . urlencode($key), [], SUPABASE_SERVICE_KEY);
}
echo json_encode(['ok' => true]);
} elseif ($action === 'upload') {
echo json_encode(['error' => 'use /api/theme-upload for file uploads']);
} else {
echo json_encode(['error' => 'invalid action']);
}
}
......@@ -8,6 +8,37 @@
<div class="toast-container" id="toast-container"></div>
<script src="/public/js/app.js"></script>
<script>
if (window.__themeAssets) {
document.addEventListener('DOMContentLoaded', () => {
const a = window.__themeAssets;
if (a['logo-text']) {
document.querySelectorAll('.header-logo, .nav-desktop-logo').forEach(el => { el.textContent = a['logo-text']; });
}
if (a['logo-image']) {
document.querySelectorAll('.header-logo').forEach(el => {
el.innerHTML = '<img src="' + a['logo-image'] + '" alt="Logo" style="height:28px;">';
});
}
if (a['favicon']) {
let link = document.querySelector('link[rel="icon"]') || document.createElement('link');
link.rel = 'icon'; link.href = a['favicon'];
document.head.appendChild(link);
}
if (a['sprite-svg']) {
document.querySelectorAll('use[href^="/public/icons/sprite.svg"]').forEach(el => {
el.setAttribute('href', el.getAttribute('href').replace('/public/icons/sprite.svg', a['sprite-svg']));
});
}
Object.keys(a).forEach(k => {
if (k.startsWith('piece-')) {
const piece = k.replace('piece-', '');
document.documentElement.style.setProperty('--piece-' + piece, 'url(' + a[k] + ')');
}
});
});
}
</script>
<?php if (isset($extraJs)): ?>
<script src="<?= $extraJs ?>"></script>
<?php endif; ?>
......
......@@ -11,6 +11,7 @@
<?php if (isset($extraCss)): ?>
<link rel="stylesheet" href="<?= $extraCss ?>">
<?php endif; ?>
<?php require_once __DIR__ . '/theme-loader.php'; ?>
</head>
<body>
<div class="app">
......
<?php
/**
* Loads theme overrides from DB and outputs a <style> block with CSS variable overrides.
* Caches in a local file for 60 seconds to avoid hitting Supabase on every request.
*/
require_once __DIR__ . '/../config/database.php';
function get_theme_overrides(): array {
$cacheFile = __DIR__ . '/../storage/theme-cache.json';
$cacheTTL = 60;
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTTL) {
$cached = json_decode(file_get_contents($cacheFile), true);
if (is_array($cached)) return $cached;
}
$res = supabase_rest('GET', 'theme_settings?select=key,value,category', [], SUPABASE_SERVICE_KEY);
$settings = ($res['status'] === 200 && is_array($res['data']) && !isset($res['data']['code'])) ? $res['data'] : [];
$cacheDir = dirname($cacheFile);
if (!is_dir($cacheDir)) mkdir($cacheDir, 0755, true);
file_put_contents($cacheFile, json_encode($settings));
return $settings;
}
function render_theme_style(): void {
$settings = get_theme_overrides();
if (empty($settings)) return;
$cssVars = [];
$assetOverrides = [];
foreach ($settings as $s) {
$key = $s['key'];
$value = $s['value'];
$category = $s['category'];
if ($category === 'assets' || $category === 'icons' || $category === 'pieces') {
$assetOverrides[$key] = $value;
} else {
$cssVars[] = ' --' . htmlspecialchars($key) . ': ' . htmlspecialchars($value) . ';';
}
}
if (!empty($cssVars)) {
echo "<style id=\"theme-overrides\">\n:root {\n" . implode("\n", $cssVars) . "\n}\n</style>\n";
}
if (!empty($assetOverrides)) {
echo "<script>window.__themeAssets = " . json_encode($assetOverrides) . ";</script>\n";
}
}
render_theme_style();
......@@ -45,6 +45,16 @@ if ($route === '' || $route === 'home') {
require 'pages/orgs.php';
} elseif ($route === 'org') {
require 'pages/org.php';
} elseif ($route === 'ludo') {
require 'pages/ludo.php';
} elseif ($route === 'ludo-game') {
require 'pages/ludo-game.php';
} elseif ($route === 'ludo-live') {
require 'pages/ludo-live.php';
} elseif ($route === 'ludo-matchmaking') {
require 'pages/ludo-matchmaking.php';
} elseif ($route === 'admin/theme') {
require 'pages/admin-theme.php';
} elseif (str_starts_with($route, 'api/')) {
$apiFile = str_replace('api/', '', $route);
$apiPath = __DIR__ . '/api/' . basename($apiFile) . '.php';
......
<?php $pageTitle = 'EL3AB - Theme Editor'; ?>
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EL3AB Admin - Theme Editor</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:'Inter',sans-serif; background:#0a0f1a; color:#e2e8f0; min-height:100vh; }
.login-screen { display:flex; align-items:center; justify-content:center; min-height:100vh; padding:20px; }
.login-box { background:#1a2332; border:1px solid rgba(255,255,255,0.1); border-radius:12px; padding:32px; max-width:360px; width:100%; }
.login-box h1 { font-size:20px; margin-bottom:20px; text-align:center; color:#e7a832; }
.login-box input { width:100%; padding:12px; margin-bottom:12px; background:#0f1925; border:1px solid rgba(255,255,255,0.1); border-radius:8px; color:#e2e8f0; font-size:14px; }
.login-box button { width:100%; padding:12px; background:#e7a832; color:#0f172a; border:none; border-radius:8px; font-weight:700; font-size:14px; cursor:pointer; }
.login-box button:hover { background:#c48b1a; }
.login-error { color:#ef4444; font-size:12px; text-align:center; margin-top:8px; display:none; }
.admin-layout { display:flex; min-height:100vh; }
.admin-sidebar { width:220px; background:#111827; border-right:1px solid rgba(255,255,255,0.08); padding:20px 0; flex-shrink:0; }
.admin-sidebar h2 { font-size:14px; padding:0 16px; margin-bottom:16px; color:#e7a832; }
.admin-sidebar .nav-item { display:block; padding:10px 16px; font-size:13px; cursor:pointer; color:#94a3b8; transition:all 0.15s; border-right:3px solid transparent; }
.admin-sidebar .nav-item:hover { color:#e2e8f0; background:rgba(255,255,255,0.04); }
.admin-sidebar .nav-item.active { color:#15d7ff; background:rgba(21,215,255,0.08); border-right-color:#15d7ff; }
.admin-main { flex:1; padding:24px 32px; overflow-y:auto; max-height:100vh; }
.admin-main h1 { font-size:22px; font-weight:700; margin-bottom:8px; }
.admin-main .subtitle { color:#64748b; font-size:13px; margin-bottom:24px; }
.section { margin-bottom:32px; }
.section-title { font-size:15px; font-weight:600; margin-bottom:14px; padding-bottom:8px; border-bottom:1px solid rgba(255,255,255,0.08); display:flex; align-items:center; gap:8px; }
.section-title .badge { font-size:10px; background:#7c4dff; padding:2px 6px; border-radius:4px; }
.field-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(260px, 1fr)); gap:12px; }
.field { background:#1a2332; border:1px solid rgba(255,255,255,0.08); border-radius:8px; padding:14px; }
.field label { display:block; font-size:11px; color:#94a3b8; margin-bottom:6px; font-weight:500; text-transform:uppercase; letter-spacing:0.5px; }
.field .input-row { display:flex; gap:8px; align-items:center; }
.field input[type="text"], .field input[type="color"] { flex:1; padding:8px 10px; background:#0f1925; border:1px solid rgba(255,255,255,0.1); border-radius:6px; color:#e2e8f0; font-size:13px; font-family:monospace; }
.field input[type="color"] { width:40px; height:34px; padding:2px; cursor:pointer; flex:none; }
.field input[type="file"] { font-size:12px; color:#94a3b8; }
.field .preview { margin-top:8px; }
.field .preview img { max-height:40px; border-radius:4px; }
.field .remove-btn { font-size:11px; color:#ef4444; cursor:pointer; background:none; border:none; padding:4px 8px; }
.save-bar { position:sticky; bottom:0; background:#111827; border-top:1px solid rgba(255,255,255,0.1); padding:16px 32px; display:flex; align-items:center; gap:12px; }
.save-bar .btn-save { padding:10px 24px; background:#15d7ff; color:#0f172a; border:none; border-radius:8px; font-weight:700; font-size:13px; cursor:pointer; }
.save-bar .btn-save:hover { background:#0ba8c9; }
.save-bar .btn-reset { padding:10px 24px; background:transparent; color:#ef4444; border:1px solid #ef4444; border-radius:8px; font-size:13px; cursor:pointer; }
.save-bar .status { font-size:12px; color:#34d399; }
.upload-area { border:2px dashed rgba(255,255,255,0.1); border-radius:8px; padding:16px; text-align:center; cursor:pointer; transition:border-color 0.2s; }
.upload-area:hover { border-color:rgba(21,215,255,0.4); }
.upload-area p { font-size:12px; color:#64748b; }
@media (max-width: 768px) {
.admin-layout { flex-direction:column; }
.admin-sidebar { width:100%; border-right:none; border-bottom:1px solid rgba(255,255,255,0.08); padding:12px 0; display:flex; overflow-x:auto; gap:0; }
.admin-sidebar h2 { display:none; }
.admin-sidebar .nav-item { white-space:nowrap; border-right:none; border-bottom:3px solid transparent; padding:8px 14px; }
.admin-sidebar .nav-item.active { border-bottom-color:#15d7ff; }
.admin-main { padding:16px; max-height:none; }
.field-grid { grid-template-columns:1fr; }
}
</style>
</head>
<body>
<!-- Login Screen -->
<div class="login-screen" id="login-screen">
<div class="login-box">
<h1>Theme Editor</h1>
<input type="text" id="admin-user" placeholder="Username" autocomplete="username">
<input type="password" id="admin-pass" placeholder="Password" autocomplete="current-password">
<button onclick="doLogin()">Login</button>
<p class="login-error" id="login-err">Invalid credentials</p>
</div>
</div>
<!-- Admin Panel -->
<div class="admin-layout" id="admin-panel" style="display:none;">
<aside class="admin-sidebar">
<h2>Theme Editor</h2>
<div class="nav-item active" data-section="colors">Colors</div>
<div class="nav-item" data-section="board">Board</div>
<div class="nav-item" data-section="moves">Move Classes</div>
<div class="nav-item" data-section="ui">UI / Overlays</div>
<div class="nav-item" data-section="bots">Bot Avatars</div>
<div class="nav-item" data-section="assets">Assets / Logo</div>
<div class="nav-item" data-section="pieces">Chess Pieces</div>
<div class="nav-item" data-section="ludo">Ludo</div>
<div class="nav-item" data-section="icons">Icons</div>
</aside>
<div class="admin-main">
<h1>Theme Customization</h1>
<p class="subtitle">Changes apply globally to all players. Leave fields empty to use defaults.</p>
<!-- Colors Section -->
<div class="section" data-panel="colors">
<div class="section-title">Background & Brand <span class="badge">CSS Vars</span></div>
<div class="field-grid" id="fields-colors"></div>
</div>
<!-- Board Section -->
<div class="section" data-panel="board">
<div class="section-title">Board Theme</div>
<div class="field-grid" id="fields-board"></div>
</div>
<!-- Move Classes -->
<div class="section" data-panel="moves">
<div class="section-title">Move Classifications</div>
<div class="field-grid" id="fields-moves"></div>
</div>
<!-- UI -->
<div class="section" data-panel="ui">
<div class="section-title">UI / Overlays / Eval Bar</div>
<div class="field-grid" id="fields-ui"></div>
</div>
<!-- Bots -->
<div class="section" data-panel="bots">
<div class="section-title">Bot Avatar Gradients</div>
<div class="field-grid" id="fields-bots"></div>
</div>
<!-- Assets -->
<div class="section" data-panel="assets">
<div class="section-title">Assets / Logo / Favicon</div>
<div class="field-grid" id="fields-assets"></div>
</div>
<!-- Chess Pieces -->
<div class="section" data-panel="pieces">
<div class="section-title">Chess Pieces (upload custom images)</div>
<div class="field-grid" id="fields-pieces"></div>
</div>
<!-- Ludo -->
<div class="section" data-panel="ludo" style="display:none;">
<div class="section-title">Ludo Game Colors</div>
<div class="field-grid" id="fields-ludo"></div>
</div>
<!-- Icons -->
<div class="section" data-panel="icons">
<div class="section-title">Custom SVG Sprite</div>
<div class="field-grid" id="fields-icons"></div>
</div>
<div class="save-bar">
<button class="btn-save" onclick="saveAll()">Save All Changes</button>
<button class="btn-reset" onclick="clearCache()">Clear Cache</button>
<span class="status" id="save-status"></span>
</div>
</div>
</div>
<script>
const THEME_FIELDS = {
colors: [
{ key:'bg-0', label:'Background Base', type:'color', default:'#050D17' },
{ key:'bg-1', label:'Background Layer 1', type:'color', default:'#0A1525' },
{ key:'bg-2', label:'Background Layer 2', type:'color', default:'#142640' },
{ key:'bg-3', label:'Background Layer 3', type:'color', default:'#1C3254' },
{ key:'gold', label:'Gold (Brand)', type:'color', default:'#E7A832' },
{ key:'gold-dark', label:'Gold Dark', type:'color', default:'#C48B1A' },
{ key:'cyan', label:'Cyan (Brand)', type:'color', default:'#15D7FF' },
{ key:'cyan-dark', label:'Cyan Dark', type:'color', default:'#0BA8C9' },
{ key:'blue', label:'Blue', type:'color', default:'#2979FF' },
{ key:'purple', label:'Purple', type:'color', default:'#7C4DFF' },
{ key:'success', label:'Success', type:'color', default:'#34D399' },
{ key:'error', label:'Error', type:'color', default:'#EF4444' },
{ key:'warning', label:'Warning', type:'color', default:'#F59E0B' },
{ key:'online', label:'Online Indicator', type:'color', default:'#22C55E' },
{ key:'text-1', label:'Text Primary', type:'color', default:'#F1F5F9' },
{ key:'text-2', label:'Text Secondary', type:'color', default:'#94A3B8' },
{ key:'text-3', label:'Text Tertiary', type:'color', default:'#64748B' },
{ key:'text-inverse', label:'Text Inverse', type:'color', default:'#0F172A' },
],
board: [
{ key:'board-light', label:'Light Square', type:'color', default:'#E8EDF9' },
{ key:'board-dark', label:'Dark Square', type:'color', default:'#7195D1' },
{ key:'board-selected', label:'Selected Square', type:'text', default:'rgba(21, 215, 255, 0.45)' },
{ key:'board-last-move', label:'Last Move Highlight', type:'text', default:'rgba(255, 199, 40, 0.35)' },
{ key:'board-check', label:'Check Highlight', type:'text', default:'rgba(239, 68, 68, 0.55)' },
{ key:'board-premove', label:'Premove Highlight', type:'text', default:'rgba(21, 180, 240, 0.3)' },
{ key:'board-highlight-green', label:'Highlight Green', type:'text', default:'rgba(21, 180, 90, 0.4)' },
{ key:'board-highlight-red', label:'Highlight Red', type:'text', default:'rgba(220, 50, 50, 0.4)' },
{ key:'board-highlight-yellow', label:'Highlight Yellow', type:'text', default:'rgba(220, 180, 30, 0.4)' },
],
moves: [
{ key:'move-brilliant', label:'Brilliant', type:'color', default:'#26c6da' },
{ key:'move-great', label:'Great', type:'color', default:'#66bb6a' },
{ key:'move-good', label:'Good', type:'color', default:'#81c784' },
{ key:'move-book', label:'Book', type:'color', default:'#9e9e9e' },
{ key:'move-inaccuracy', label:'Inaccuracy', type:'color', default:'#fdd835' },
{ key:'move-mistake', label:'Mistake', type:'color', default:'#ef6c00' },
{ key:'move-blunder', label:'Blunder', type:'color', default:'#e53935' },
{ key:'move-text-brilliant', label:'Text Brilliant', type:'color', default:'#00bcd4' },
{ key:'move-text-great', label:'Text Great', type:'color', default:'#2196f3' },
{ key:'move-text-good', label:'Text Good', type:'color', default:'#4caf50' },
{ key:'move-text-inaccuracy', label:'Text Inaccuracy', type:'color', default:'#ff9800' },
{ key:'move-text-mistake', label:'Text Mistake', type:'color', default:'#f44336' },
{ key:'move-text-blunder', label:'Text Blunder', type:'color', default:'#d32f2f' },
],
ui: [
{ key:'eval-bg', label:'Eval Bar BG', type:'color', default:'#1a1a2e' },
{ key:'eval-white', label:'Eval White Fill', type:'color', default:'#f0f0f0' },
{ key:'eval-label-light', label:'Eval Label Light', type:'color', default:'#ffffff' },
{ key:'eval-label-dark', label:'Eval Label Dark', type:'color', default:'#333333' },
{ key:'arrow-green', label:'Arrow Green', type:'text', default:'rgba(21, 180, 90, 0.7)' },
{ key:'arrow-red', label:'Arrow Red', type:'text', default:'rgba(220, 50, 50, 0.7)' },
{ key:'arrow-yellow', label:'Arrow Yellow', type:'text', default:'rgba(220, 180, 30, 0.7)' },
{ key:'graph-bg', label:'Graph Background', type:'color', default:'#0a1628' },
{ key:'graph-grid', label:'Graph Grid', type:'color', default:'#333333' },
{ key:'graph-accent', label:'Graph Accent Line', type:'color', default:'#15d7ff' },
{ key:'graph-error', label:'Graph Error Dots', type:'color', default:'#f44336' },
{ key:'overlay-dark', label:'Overlay Dark', type:'text', default:'rgba(0, 0, 0, 0.8)' },
{ key:'overlay-result', label:'Game Result Overlay', type:'text', default:'rgba(5, 13, 23, 0.92)' },
{ key:'overlay-error-bg', label:'Error Alert BG', type:'text', default:'rgba(239, 68, 68, 0.1)' },
{ key:'overlay-error-border', label:'Error Alert Border', type:'text', default:'rgba(239, 68, 68, 0.2)' },
],
bots: [
{ key:'bot-amina', label:'Amina (Beginner)', type:'text', default:'#4ade80, #22c55e' },
{ key:'bot-tarek', label:'Tarek (Amateur)', type:'text', default:'#38bdf8, #0ea5e9' },
{ key:'bot-nour', label:'Nour (Intermediate)', type:'text', default:'#a78bfa, #7c3aed' },
{ key:'bot-omar', label:'Omar (Good)', type:'text', default:'#fb923c, #ea580c' },
{ key:'bot-layla', label:'Layla (Strong)', type:'text', default:'#f472b6, #db2777' },
{ key:'bot-ziad', label:'Ziad (Expert)', type:'text', default:'#f87171, #dc2626' },
{ key:'bot-gm', label:'Grandmaster', type:'text', default:'var(--gold), #b45309' },
],
assets: [
{ key:'logo-text', label:'Logo Text Override', type:'text', default:'', category:'assets' },
{ key:'logo-image', label:'Logo Image', type:'file', category:'assets' },
{ key:'favicon', label:'Favicon', type:'file', category:'assets' },
],
pieces: [
{ key:'piece-wK', label:'White King', type:'file', category:'pieces' },
{ key:'piece-wQ', label:'White Queen', type:'file', category:'pieces' },
{ key:'piece-wR', label:'White Rook', type:'file', category:'pieces' },
{ key:'piece-wB', label:'White Bishop', type:'file', category:'pieces' },
{ key:'piece-wN', label:'White Knight', type:'file', category:'pieces' },
{ key:'piece-wP', label:'White Pawn', type:'file', category:'pieces' },
{ key:'piece-bK', label:'Black King', type:'file', category:'pieces' },
{ key:'piece-bQ', label:'Black Queen', type:'file', category:'pieces' },
{ key:'piece-bR', label:'Black Rook', type:'file', category:'pieces' },
{ key:'piece-bB', label:'Black Bishop', type:'file', category:'pieces' },
{ key:'piece-bN', label:'Black Knight', type:'file', category:'pieces' },
{ key:'piece-bP', label:'Black Pawn', type:'file', category:'pieces' },
],
ludo: [
{ key:'ludo-p1', label:'Player 1 (Red)', type:'color', default:'#E53935' },
{ key:'ludo-p2', label:'Player 2 (Green)', type:'color', default:'#43A047' },
{ key:'ludo-p3', label:'Player 3 (Yellow)', type:'color', default:'#FDD835' },
{ key:'ludo-p4', label:'Player 4 (Blue)', type:'color', default:'#1E88E5' },
{ key:'ludo-board-bg', label:'Board Background', type:'color', default:'#1a2332' },
{ key:'ludo-path', label:'Path Cell Color', type:'color', default:'#f5f5f5' },
{ key:'ludo-safe', label:'Safe Position Color', type:'color', default:'#FFD54F' },
{ key:'ludo-home-p1', label:'Home Entrance P1', type:'text', default:'rgba(229,57,53,0.3)' },
{ key:'ludo-home-p2', label:'Home Entrance P2', type:'text', default:'rgba(67,160,71,0.3)' },
{ key:'ludo-home-p3', label:'Home Entrance P3', type:'text', default:'rgba(253,216,53,0.3)' },
{ key:'ludo-home-p4', label:'Home Entrance P4', type:'text', default:'rgba(30,136,229,0.3)' },
{ key:'ludo-dice-bg', label:'Dice Background', type:'text', default:'var(--bg-2)' },
{ key:'ludo-dice-dot', label:'Dice Dot Color', type:'text', default:'var(--text-inverse)' },
{ key:'ludo-chat-bg', label:'Chat Background', type:'text', default:'var(--bg-2)' },
],
icons: [
{ key:'sprite-svg', label:'Custom SVG Sprite (replaces default)', type:'file', category:'icons' },
],
};
let adminUser = '';
let adminPass = '';
let currentSettings = {};
function doLogin() {
adminUser = document.getElementById('admin-user').value;
adminPass = document.getElementById('admin-pass').value;
if (adminUser === 'admin' && adminPass === 'Alarcade123#') {
document.getElementById('login-screen').style.display = 'none';
document.getElementById('admin-panel').style.display = 'flex';
loadTheme();
} else {
document.getElementById('login-err').style.display = 'block';
}
}
document.getElementById('admin-pass').addEventListener('keydown', e => { if(e.key==='Enter') doLogin(); });
async function loadTheme() {
const res = await fetch('/api/theme');
const data = await res.json();
currentSettings = {};
(data.settings || []).forEach(s => { currentSettings[s.key] = s.value; });
renderAllFields();
}
function renderAllFields() {
Object.keys(THEME_FIELDS).forEach(section => {
const container = document.getElementById('fields-' + section);
if (!container) return;
container.innerHTML = '';
THEME_FIELDS[section].forEach(f => {
const saved = currentSettings[f.key] || '';
container.innerHTML += renderField(f, saved);
});
});
}
function renderField(f, saved) {
if (f.type === 'color') {
const colorVal = saved || f.default;
return `<div class="field">
<label>${f.label} <code style="font-size:10px;color:#64748b;">(--${f.key})</code></label>
<div class="input-row">
<input type="color" value="${colorVal}" data-key="${f.key}" data-category="${f.category || 'colors'}" onchange="onColorChange(this)">
<input type="text" value="${saved}" data-key="${f.key}" data-category="${f.category || 'colors'}" placeholder="${f.default}">
${saved ? '<button class="remove-btn" onclick="removeField(this,\'' + f.key + '\')">X</button>' : ''}
</div>
</div>`;
} else if (f.type === 'text') {
return `<div class="field">
<label>${f.label} <code style="font-size:10px;color:#64748b;">(--${f.key})</code></label>
<div class="input-row">
<input type="text" value="${saved}" data-key="${f.key}" data-category="${f.category || 'colors'}" placeholder="${f.default}">
${saved ? '<button class="remove-btn" onclick="removeField(this,\'' + f.key + '\')">X</button>' : ''}
</div>
</div>`;
} else if (f.type === 'file') {
return `<div class="field">
<label>${f.label} <code style="font-size:10px;color:#64748b;">(${f.key})</code></label>
<input type="file" accept="image/*,.svg" data-key="${f.key}" data-category="${f.category || 'assets'}" onchange="uploadFile(this)">
${saved ? '<div class="preview"><img src="' + saved + '"><button class="remove-btn" onclick="removeAsset(this,\'' + f.key + '\')">Remove</button></div>' : ''}
</div>`;
}
return '';
}
function onColorChange(colorInput) {
const row = colorInput.closest('.input-row');
const textInput = row.querySelector('input[type="text"]');
textInput.value = colorInput.value;
}
async function uploadFile(input) {
const file = input.files[0];
if (!file) return;
const fd = new FormData();
fd.append('file', file);
fd.append('key', input.dataset.key);
fd.append('category', input.dataset.category);
fd.append('admin_user', adminUser);
fd.append('admin_pass', adminPass);
fd.append('label', input.dataset.key);
const res = await fetch('/api/theme-upload', { method:'POST', body:fd });
const data = await res.json();
if (data.ok) {
currentSettings[input.dataset.key] = data.url;
showStatus('Uploaded: ' + input.dataset.key);
renderAllFields();
} else {
showStatus('Error: ' + (data.error || 'upload failed'), true);
}
}
async function removeField(btn, key) {
await fetch('/api/theme', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({ admin_user:adminUser, admin_pass:adminPass, action:'delete', key })
});
delete currentSettings[key];
renderAllFields();
showStatus('Removed: ' + key);
}
async function removeAsset(btn, key) {
await removeField(btn, key);
}
async function saveAll() {
const inputs = document.querySelectorAll('.field input[type="text"][data-key]');
const settings = [];
inputs.forEach(input => {
const key = input.dataset.key;
const value = input.value.trim();
const category = input.dataset.category || 'colors';
if (value) {
settings.push({ key, value, category, label:key });
}
});
if (settings.length === 0) {
showStatus('Nothing to save');
return;
}
const res = await fetch('/api/theme', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({ admin_user:adminUser, admin_pass:adminPass, action:'save', settings })
});
const data = await res.json();
if (data.ok) {
showStatus('Saved ' + settings.length + ' settings!');
currentSettings = {};
settings.forEach(s => { currentSettings[s.key] = s.value; });
} else {
showStatus('Error saving', true);
}
}
async function clearCache() {
showStatus('Cache cleared - next page load will fetch fresh data');
}
function showStatus(msg, isErr) {
const el = document.getElementById('save-status');
el.textContent = msg;
el.style.color = isErr ? '#ef4444' : '#34d399';
setTimeout(() => { el.textContent = ''; }, 4000);
}
// Sidebar nav
document.querySelectorAll('.admin-sidebar .nav-item').forEach(item => {
item.addEventListener('click', () => {
document.querySelectorAll('.admin-sidebar .nav-item').forEach(n => n.classList.remove('active'));
item.classList.add('active');
const section = item.dataset.section;
document.querySelectorAll('.section').forEach(s => {
s.style.display = s.dataset.panel === section ? 'block' : 'none';
});
});
});
// Show only first section on load
document.querySelectorAll('.section').forEach((s, i) => { s.style.display = i === 0 ? 'block' : 'none'; });
</script>
</body>
</html>
......@@ -189,7 +189,7 @@ $extraCss = '/public/css/chessboard.css';
.eval-bar {
width: 24px;
min-height: 100%;
background: #333;
background: var(--eval-bg);
border-radius: var(--radius-sm);
position: relative;
overflow: hidden;
......@@ -203,7 +203,7 @@ $extraCss = '/public/css/chessboard.css';
left: 0;
right: 0;
height: 50%;
background: #f0f0f0;
background: var(--eval-white);
transition: height 0.3s ease;
}
......@@ -219,7 +219,7 @@ $extraCss = '/public/css/chessboard.css';
writing-mode: vertical-lr;
text-orientation: mixed;
z-index: 1;
text-shadow: 0 0 3px rgba(0,0,0,0.8);
text-shadow: 0 0 3px var(--overlay-dark);
}
.analysis-board-section .board-wrapper {
......@@ -279,13 +279,13 @@ $extraCss = '/public/css/chessboard.css';
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; }
.move-class-brilliant { color: var(--move-text-brilliant); }
.move-class-great { color: var(--move-text-great); }
.move-class-good { color: var(--move-text-good); }
.move-class-book { color: var(--move-book); }
.move-class-inaccuracy { color: var(--move-text-inaccuracy); }
.move-class-mistake { color: var(--move-text-mistake); }
.move-class-blunder { color: var(--move-text-blunder); }
/* Sections */
.analysis-section {
......
......@@ -12,13 +12,13 @@
<?php
$bots = [
['id' => 'amina', 'name' => 'امينة', 'level' => 'مبتدئة', 'elo' => 800, 'letter' => 'A', 'gradient' => '#4ade80,#22c55e', 'bar' => 11, 'barColor' => 'var(--success)'],
['id' => 'tarek', 'name' => 'طارق', 'level' => 'هاوي', 'elo' => 1000, 'letter' => 'T', 'gradient' => '#38bdf8,#0ea5e9', 'bar' => 25, 'barColor' => 'var(--success)'],
['id' => 'nour', 'name' => 'نور', 'level' => 'متوسطة', 'elo' => 1200, 'letter' => 'N', 'gradient' => '#a78bfa,#7c3aed', 'bar' => 40, 'barColor' => 'var(--warning)'],
['id' => 'omar', 'name' => 'عمر', 'level' => 'جيد', 'elo' => 1400, 'letter' => 'O', 'gradient' => '#fb923c,#ea580c', 'bar' => 55, 'barColor' => 'var(--warning)'],
['id' => 'layla', 'name' => 'ليلى', 'level' => 'قوية', 'elo' => 1600, 'letter' => 'L', 'gradient' => '#f472b6,#db2777', 'bar' => 70, 'barColor' => 'var(--error)'],
['id' => 'ziad', 'name' => 'زياد', 'level' => 'خبير', 'elo' => 1800, 'letter' => 'Z', 'gradient' => '#f87171,#dc2626', 'bar' => 85, 'barColor' => 'var(--error)'],
['id' => 'grandmaster', 'name' => 'الاستاذ الكبير', 'level' => 'جراند ماستر', 'elo' => 2200, 'letter' => 'GM', 'gradient' => 'var(--gold),#b45309', 'bar' => 100, 'barColor' => 'var(--purple)'],
['id' => 'amina', 'name' => 'امينة', 'level' => 'مبتدئة', 'elo' => 800, 'letter' => 'A', 'gradient' => 'var(--bot-amina)', 'bar' => 11, 'barColor' => 'var(--success)'],
['id' => 'tarek', 'name' => 'طارق', 'level' => 'هاوي', 'elo' => 1000, 'letter' => 'T', 'gradient' => 'var(--bot-tarek)', 'bar' => 25, 'barColor' => 'var(--success)'],
['id' => 'nour', 'name' => 'نور', 'level' => 'متوسطة', 'elo' => 1200, 'letter' => 'N', 'gradient' => 'var(--bot-nour)', 'bar' => 40, 'barColor' => 'var(--warning)'],
['id' => 'omar', 'name' => 'عمر', 'level' => 'جيد', 'elo' => 1400, 'letter' => 'O', 'gradient' => 'var(--bot-omar)', 'bar' => 55, 'barColor' => 'var(--warning)'],
['id' => 'layla', 'name' => 'ليلى', 'level' => 'قوية', 'elo' => 1600, 'letter' => 'L', 'gradient' => 'var(--bot-layla)', 'bar' => 70, 'barColor' => 'var(--error)'],
['id' => 'ziad', 'name' => 'زياد', 'level' => 'خبير', 'elo' => 1800, 'letter' => 'Z', 'gradient' => 'var(--bot-ziad)', 'bar' => 85, 'barColor' => 'var(--error)'],
['id' => 'grandmaster', 'name' => 'الاستاذ الكبير', 'level' => 'جراند ماستر', 'elo' => 2200, 'letter' => 'GM', 'gradient' => 'var(--bot-gm)', 'bar' => 100, 'barColor' => 'var(--purple)'],
];
foreach ($bots as $bot): ?>
<div class="card card-hover bot-card" data-bot="<?= $bot['id'] ?>" data-elo="<?= $bot['elo'] ?>">
......@@ -26,7 +26,7 @@ foreach ($bots as $bot): ?>
<img src="https://stockfishapi.caprover.al-arcade.com/portraits/<?= $bot['id'] ?>.jpg"
class="avatar" style="object-fit:cover;"
onerror="this.style.display='none';this.nextElementSibling.style.display='flex';">
<div class="avatar" style="display:none;background:linear-gradient(135deg,<?= $bot['gradient'] ?>);color:#fff;font-weight:700;font-size:18px;align-items:center;justify-content:center;"><?= $bot['letter'] ?></div>
<div class="avatar" style="display:none;background:linear-gradient(135deg,<?= $bot['gradient'] ?>);color:var(--text-1);font-weight:700;font-size:18px;align-items:center;justify-content:center;"><?= $bot['letter'] ?></div>
<div style="flex:1;">
<p style="font-size:16px;font-weight:600;"><?= $bot['name'] ?></p>
<p class="text-muted text-xs"><?= $bot['level'] ?> - ELO <?= $bot['elo'] ?></p>
......
......@@ -37,7 +37,7 @@
ما عندك حساب؟ <a href="/register" style="color:var(--cyan);">سجل الان</a>
</p>
<div id="login-error" style="display:none;margin-top:16px;padding:12px;background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.2);border-radius:var(--radius-md);color:var(--error);font-size:13px;text-align:center;"></div>
<div id="login-error" style="display:none;margin-top:16px;padding:12px;background:var(--overlay-error-bg);border:1px solid var(--overlay-error-border);border-radius:var(--radius-md);color:var(--error);font-size:13px;text-align:center;"></div>
</div>
</div>
......
<?php
$pageTitle = 'EL3AB - لودو';
$extraCss = ['/public/css/ludo.css'];
$extraJs = ['/public/js/ludo-constants.js', '/public/js/ludo-ui.js', '/public/js/ludo-bot.js', '/public/js/ludo-game.js'];
?>
<?php require __DIR__ . '/../includes/header.php'; ?>
<div class="ludo-layout">
<div class="ludo-board-column">
<div class="ludo-players-row" id="ludo-top-players"></div>
<div class="ludo-board-wrapper">
<div id="ludo-board"></div>
</div>
<div class="ludo-players-row" id="ludo-bottom-players"></div>
<div class="ludo-mobile-panel">
<div class="ludo-turn-indicator" id="ludo-turn-mobile"></div>
<div id="ludo-dice-container-mobile"></div>
</div>
</div>
<div class="ludo-side-panel">
<div class="ludo-turn-indicator" id="ludo-turn"></div>
<div id="ludo-dice-container"></div>
<div class="ludo-log" id="ludo-log"></div>
</div>
</div>
<script>
(function() {
var params = new URLSearchParams(window.location.search);
var mode = params.get('mode') || 'local';
var playerCount = parseInt(params.get('players') || '4', 10);
var botCount = parseInt(params.get('bots') || '1', 10);
var difficulty = params.get('difficulty') || 'easy';
var allPlayers = ['P1', 'P2', 'P3', 'P4'];
var activePlayers, bots, playerNames;
if (mode === 'bot') {
var totalPlayers = 1 + botCount;
if (totalPlayers > 4) totalPlayers = 4;
activePlayers = allPlayers.slice(0, totalPlayers);
bots = {};
playerNames = {};
playerNames['P1'] = 'انت';
for (var i = 1; i < totalPlayers; i++) {
bots[allPlayers[i]] = difficulty;
playerNames[allPlayers[i]] = 'بوت ' + i;
}
} else {
if (playerCount < 2) playerCount = 2;
if (playerCount > 4) playerCount = 4;
activePlayers = allPlayers.slice(0, playerCount);
bots = {};
playerNames = {};
activePlayers.forEach(function(p, idx) {
playerNames[p] = LudoConstants.PLAYER_LABELS[p];
});
}
document.addEventListener('DOMContentLoaded', function() {
LudoUI.renderBoard('#ludo-board');
var isMobile = window.innerWidth < 768;
var diceContainer = isMobile ? '#ludo-dice-container-mobile' : '#ludo-dice-container';
var turnEl = isMobile ? document.getElementById('ludo-turn-mobile') : document.getElementById('ludo-turn');
LudoUI.renderDice(diceContainer);
LudoUI.setTurnElement(turnEl);
LudoUI.setLogElement(document.getElementById('ludo-log'));
LudoUI.renderPlayerCards(
document.getElementById('ludo-top-players'),
document.getElementById('ludo-bottom-players'),
activePlayers,
playerNames
);
LudoGame.init({
players: activePlayers,
mode: mode,
bots: bots,
difficulty: difficulty,
playerNames: playerNames,
onGameEnd: function(winners) {
var playAgainBtn = document.getElementById('ludo-play-again');
if (playAgainBtn) {
playAgainBtn.addEventListener('click', function() {
LudoGame.restart({
players: activePlayers,
mode: mode,
bots: bots,
difficulty: difficulty,
playerNames: playerNames,
onGameEnd: arguments.callee
});
});
}
}
});
});
})();
</script>
<?php require __DIR__ . '/../includes/footer.php'; ?>
<?php
$pageTitle = 'EL3AB - لودو اونلاين';
$extraCss = ['/public/css/ludo.css'];
$extraJs = ['/public/js/ludo-constants.js', '/public/js/ludo-ui.js', '/public/js/ludo-bot.js', '/public/js/ludo-chat.js', '/public/js/ludo-live.js'];
?>
<?php require __DIR__ . '/../includes/header.php'; ?>
<div class="ludo-layout" id="ludo-page" style="display:none;">
<div class="ludo-board-column">
<div class="ludo-players-row" id="ludo-top-players"></div>
<div class="ludo-board-wrapper">
<div id="ludo-board"></div>
</div>
<div class="ludo-players-row" id="ludo-bottom-players"></div>
<div class="ludo-mobile-panel">
<div class="ludo-turn-indicator" id="ludo-turn-mobile"></div>
<div id="ludo-dice-container-mobile"></div>
</div>
</div>
<div class="ludo-side-panel">
<div class="ludo-turn-indicator" id="ludo-turn"></div>
<div id="ludo-dice-container"></div>
<div class="ludo-log" id="ludo-log"></div>
<div id="ludo-chat"></div>
</div>
</div>
<!-- Waiting Room -->
<div id="ludo-waiting" class="space-y-6" style="padding:24px;text-align:center;">
<h2 style="font-size:22px;font-weight:700;">غرفة لودو</h2>
<div class="card" id="waiting-card">
<div class="card-body space-y-4">
<div id="room-code-display" style="display:none;">
<p class="text-muted text-sm">كود الغرفة</p>
<p id="room-code-value" style="font-size:32px;font-weight:700;letter-spacing:6px;color:var(--gold);"></p>
<button class="btn btn-ghost btn-sm" onclick="copyRoomCode()">نسخ الكود</button>
</div>
<div id="waiting-players" class="space-y-2"></div>
<div id="waiting-status">
<div class="spinner" style="margin:0 auto;"></div>
<p class="text-muted text-sm" style="margin-top:8px;">في انتظار اللاعبين...</p>
</div>
<button class="btn btn-gold btn-block" id="start-game-btn" style="display:none;" onclick="startGame()">ابدأ اللعبة</button>
<button class="btn btn-ghost btn-block" onclick="leaveRoom()">مغادرة</button>
</div>
</div>
</div>
<script>
(function() {
var params = new URLSearchParams(window.location.search);
var action = params.get('action');
var code = params.get('code');
var matchId = params.get('id');
var session = App.getSession ? App.getSession() : null;
if (!session || !session.access_token) {
window.location.href = '/login';
return;
}
var token = session.access_token;
var userId = session.user ? session.user.id : null;
if (matchId) {
startLiveGame(matchId);
return;
}
if (action === 'create') {
LudoLive.createRoom(4, [], function(data) {
if (data.ok) {
showWaitingRoom(data.match, data.room_code);
initLiveConnection(data.match.id);
} else {
App.toast(data.error || 'خطأ', 'error');
}
});
} else if (action === 'join' && code) {
LudoLive.joinRoom(code, function(data) {
if (data.ok) {
showWaitingRoom(data.match, data.match.room_code);
initLiveConnection(data.match.id);
} else {
App.toast(data.error === 'room_not_found' ? 'الغرفة غير موجودة' : (data.error || 'خطأ'), 'error');
setTimeout(function() { window.location.href = '/ludo'; }, 1500);
}
});
}
// Expose token/userId for LudoLive
window.__ludoToken = token;
window.__ludoUserId = userId;
function initLiveConnection(id) {
LudoLive.init({
matchId: id,
userId: userId,
token: token
});
}
function showWaitingRoom(match, roomCode) {
var codeDisplay = document.getElementById('room-code-display');
var codeValue = document.getElementById('room-code-value');
codeDisplay.style.display = 'block';
codeValue.textContent = roomCode || match.room_code || '';
updateWaitingPlayers(match);
if (match.host_id === userId) {
document.getElementById('start-game-btn').style.display = 'block';
}
// Watch for status change
var checkInterval = setInterval(function() {
var st = LudoLive.getState();
if (st.match) {
var m = st.match;
var status = m.status;
updateWaitingPlayers(m);
if (status === 'in_progress') {
clearInterval(checkInterval);
startLiveGame(m.id);
}
}
}, 1000);
}
function updateWaitingPlayers(match) {
var players = typeof match.players === 'string' ? JSON.parse(match.players) : match.players;
var container = document.getElementById('waiting-players');
container.innerHTML = '';
players.forEach(function(p) {
var el = document.createElement('div');
el.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px;border-radius:8px;background:var(--bg-3);';
el.innerHTML = '<div style="width:12px;height:12px;border-radius:50%;background:var(--ludo-' + p.color.toLowerCase() + ');"></div>' +
'<span>' + p.name + '</span>' +
'<span class="text-muted text-sm" style="margin-right:auto;">' + (p.type === 'bot' ? 'بوت' : '') + '</span>';
container.appendChild(el);
});
}
window.startGame = function() {
LudoLive.startGame(function(data) {
if (data.ok) {
startLiveGame(data.match.id);
} else {
App.toast(data.error || 'خطأ', 'error');
}
});
};
window.startLiveGame = startLiveGame;
function startLiveGame(id) {
document.getElementById('ludo-waiting').style.display = 'none';
document.getElementById('ludo-page').style.display = '';
if (!LudoLive.getState().matchId) {
LudoLive.init({
matchId: id,
userId: userId,
token: token
});
}
LudoLive.bindUI();
LudoLive.fetchState();
}
window.leaveRoom = function() {
LudoLive.leave();
window.location.href = '/ludo';
};
window.copyRoomCode = function() {
var code = document.getElementById('room-code-value').textContent;
if (navigator.clipboard) {
navigator.clipboard.writeText(code);
App.toast('تم نسخ الكود', 'success');
}
};
})();
</script>
<?php require __DIR__ . '/../includes/footer.php'; ?>
<?php
$pageTitle = 'EL3AB - البحث عن خصم';
$extraCss = ['/public/css/ludo.css'];
?>
<?php require __DIR__ . '/../includes/header.php'; ?>
<div class="space-y-6" style="padding:24px;text-align:center;">
<h2 style="font-size:22px;font-weight:700;">البحث عن خصم - لودو</h2>
<div class="card">
<div class="card-body space-y-4" style="padding:32px;">
<div id="mm-searching">
<div style="width:80px;height:80px;margin:0 auto;border-radius:50%;background:linear-gradient(135deg, var(--ludo-p1), var(--ludo-p3));display:flex;align-items:center;justify-content:center;">
<svg class="icon-lg" style="color:var(--text-1);animation:spin 2s linear infinite;"><use href="/public/icons/sprite.svg#icon-search"></use></svg>
</div>
<p style="font-size:18px;font-weight:600;margin-top:16px;">جاري البحث...</p>
<p class="text-muted text-sm">نبحث عن لاعب بمستواك</p>
<div id="mm-timer" style="font-size:24px;font-weight:700;color:var(--gold);margin-top:12px;">0:00</div>
</div>
<button class="btn btn-ghost btn-block" onclick="cancelSearch()">الغاء</button>
</div>
</div>
</div>
<style>
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
</style>
<script>
(function() {
var session = App.getSession ? App.getSession() : null;
if (!session || !session.access_token) {
window.location.href = '/login';
return;
}
var token = session.access_token;
var timerEl = document.getElementById('mm-timer');
var startTime = Date.now();
var pollInterval = null;
var timerInterval = null;
var cancelled = false;
function updateTimer() {
var elapsed = Math.floor((Date.now() - startTime) / 1000);
var mins = Math.floor(elapsed / 60);
var secs = elapsed % 60;
timerEl.textContent = mins + ':' + (secs < 10 ? '0' : '') + secs;
}
function poll() {
if (cancelled) return;
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/ludo', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.setRequestHeader('Authorization', 'Bearer ' + token);
xhr.onload = function() {
try {
var data = JSON.parse(xhr.responseText);
if (data.ok && data.status === 'matched' && data.match_id) {
clearInterval(pollInterval);
clearInterval(timerInterval);
window.location.href = '/ludo-live?id=' + data.match_id;
}
} catch(e) {}
};
xhr.send(JSON.stringify({ action: 'matchmake', sub_action: 'join' }));
}
timerInterval = setInterval(updateTimer, 1000);
poll();
pollInterval = setInterval(poll, 3000);
window.cancelSearch = function() {
cancelled = true;
clearInterval(pollInterval);
clearInterval(timerInterval);
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/ludo', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.setRequestHeader('Authorization', 'Bearer ' + token);
xhr.onload = function() { window.location.href = '/ludo'; };
xhr.send(JSON.stringify({ action: 'matchmake', sub_action: 'leave' }));
};
})();
</script>
<?php require __DIR__ . '/../includes/footer.php'; ?>
<?php $pageTitle = 'EL3AB - لودو'; ?>
<?php require __DIR__ . '/../includes/header.php'; ?>
<div class="space-y-6">
<div class="text-center">
<h2 style="font-size:22px;font-weight:700;">لودو</h2>
<p class="text-muted text-sm">اختر نوع اللعب</p>
</div>
<div class="space-y-3">
<!-- Local (Pass & Play) -->
<div class="card card-hover" style="cursor:pointer;" onclick="startLocal()">
<div class="card-body" style="display:flex;align-items:center;gap:16px;">
<div class="avatar" style="background:linear-gradient(135deg, var(--ludo-p1), var(--ludo-p3));">
<svg class="icon-lg" style="color:var(--text-1)"><use href="/public/icons/sprite.svg#icon-users"></use></svg>
</div>
<div style="flex:1;">
<p style="font-size:16px;font-weight:600;">لعب محلي</p>
<p class="text-muted text-sm">2-4 لاعبين على نفس الجهاز</p>
</div>
<svg class="icon" style="color:var(--text-3);transform:scaleX(-1)"><use href="/public/icons/sprite.svg#icon-arrow-right"></use></svg>
</div>
</div>
<!-- VS Bot -->
<div class="card">
<div class="card-body space-y-3">
<div style="display:flex;align-items:center;gap:12px;">
<div class="avatar" style="background:var(--bg-3);">
<svg class="icon-lg" style="color:var(--cyan)"><use href="/public/icons/sprite.svg#icon-bot"></use></svg>
</div>
<div>
<p style="font-size:16px;font-weight:600;">ضد البوت</p>
<p class="text-muted text-sm">العب ضد 1-3 بوتات</p>
</div>
</div>
<div>
<label class="input-label">عدد البوتات</label>
<div class="tab-group" id="bot-count-tabs">
<button class="tab active" data-count="1">1</button>
<button class="tab" data-count="2">2</button>
<button class="tab" data-count="3">3</button>
</div>
</div>
<div>
<label class="input-label">الصعوبة</label>
<div class="tab-group" id="bot-diff-tabs">
<button class="tab active" data-diff="easy">سهل</button>
<button class="tab" data-diff="hard">صعب</button>
</div>
</div>
<button class="btn btn-gold btn-block" onclick="startBot()">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-play"></use></svg>
ابدأ اللعب
</button>
</div>
</div>
<!-- Multiplayer -->
<div class="card" style="border-color:var(--gold);border-width:2px;">
<div class="card-body space-y-3">
<div style="display:flex;align-items:center;gap:12px;">
<div class="avatar" style="background:linear-gradient(135deg, var(--gold), var(--cyan));">
<svg class="icon-lg" style="color:var(--text-1)"><use href="/public/icons/sprite.svg#icon-sword"></use></svg>
</div>
<div>
<p style="font-size:18px;font-weight:700;">ضد لاعب حقيقي</p>
<p class="text-muted text-sm">العب اونلاين ضد لاعبين حقيقيين</p>
</div>
</div>
<button class="btn btn-gold btn-block btn-lg" onclick="startMatchmaking()">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-search"></use></svg>
ابحث عن خصم
</button>
</div>
</div>
<!-- Private Room -->
<div class="card">
<div class="card-body space-y-3">
<div style="display:flex;align-items:center;gap:12px;">
<div class="avatar" style="background:var(--bg-3);">
<svg class="icon-lg" style="color:var(--ludo-p4)"><use href="/public/icons/sprite.svg#icon-lock"></use></svg>
</div>
<div>
<p style="font-size:16px;font-weight:600;">غرفة خاصة</p>
<p class="text-muted text-sm">انشئ غرفة وادعو اصحابك</p>
</div>
</div>
<div style="display:flex;gap:8px;">
<button class="btn btn-gold" style="flex:1;" onclick="createRoom()">انشئ غرفة</button>
<button class="btn btn-ghost" style="flex:1;" onclick="showJoinRoom()">انضم بكود</button>
</div>
<div id="join-room-form" style="display:none;">
<div style="display:flex;gap:8px;">
<input type="text" class="input" id="room-code-input" placeholder="ادخل كود الغرفة" maxlength="6" style="text-transform:uppercase;letter-spacing:4px;text-align:center;">
<button class="btn btn-gold" onclick="joinRoom()">دخول</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.tab-group').forEach(function(group) {
group.querySelectorAll('.tab').forEach(function(tab) {
tab.addEventListener('click', function() {
group.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
tab.classList.add('active');
});
});
});
});
function startLocal() {
window.location.href = '/ludo-game?mode=local&players=4';
}
function startBot() {
var count = document.querySelector('#bot-count-tabs .tab.active').dataset.count;
var diff = document.querySelector('#bot-diff-tabs .tab.active').dataset.diff;
window.location.href = '/ludo-game?mode=bot&bots=' + count + '&difficulty=' + diff;
}
function startMatchmaking() {
if (!App.isLoggedIn()) { window.location.href = '/login'; return; }
window.location.href = '/ludo-matchmaking';
}
function createRoom() {
if (!App.isLoggedIn()) { window.location.href = '/login'; return; }
window.location.href = '/ludo-live?action=create';
}
function showJoinRoom() {
var form = document.getElementById('join-room-form');
form.style.display = form.style.display === 'none' ? 'block' : 'none';
if (form.style.display === 'block') {
document.getElementById('room-code-input').focus();
}
}
function joinRoom() {
if (!App.isLoggedIn()) { window.location.href = '/login'; return; }
var code = document.getElementById('room-code-input').value.trim().toUpperCase();
if (!code || code.length < 4) {
App.toast('ادخل كود صحيح', 'error');
return;
}
window.location.href = '/ludo-live?action=join&code=' + code;
}
</script>
<?php require __DIR__ . '/../includes/footer.php'; ?>
......@@ -172,7 +172,7 @@ $extraCss = '/public/css/chessboard.css';
.mm-found-icon {
width: 40px;
height: 40px;
color: #fff;
color: var(--text-1);
}
</style>
......
......@@ -44,7 +44,7 @@
</div>
<!-- Create Modal (hidden) -->
<div id="create-org-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.8);z-index:100;display:none;align-items:center;justify-content:center;padding:20px;">
<div id="create-org-modal" style="display:none;position:fixed;inset:0;background:var(--overlay-dark);z-index:100;display:none;align-items:center;justify-content:center;padding:20px;">
<div class="card" style="max-width:400px;width:100%;">
<div class="card-body space-y-4" style="padding:24px;">
<p style="font-size:18px;font-weight:700;text-align:center;">انشاء منظمة</p>
......
......@@ -16,7 +16,7 @@
<div class="card-body space-y-3">
<div style="display:flex;align-items:center;gap:12px;">
<div class="avatar" style="background:linear-gradient(135deg, var(--gold), var(--cyan));">
<svg class="icon-lg" style="color:#fff"><use href="/public/icons/sprite.svg#icon-sword"></use></svg>
<svg class="icon-lg" style="color:var(--text-1)"><use href="/public/icons/sprite.svg#icon-sword"></use></svg>
</div>
<div>
<p style="font-size:18px;font-weight:700;">ضد لاعب حقيقي</p>
......
......@@ -41,7 +41,7 @@
عندك حساب؟ <a href="/login" style="color:var(--cyan);">سجل دخول</a>
</p>
<div id="reg-error" style="display:none;margin-top:16px;padding:12px;background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.2);border-radius:var(--radius-md);color:var(--error);font-size:13px;text-align:center;"></div>
<div id="reg-error" style="display:none;margin-top:16px;padding:12px;background:var(--overlay-error-bg);border:1px solid var(--overlay-error-border);border-radius:var(--radius-md);color:var(--error);font-size:13px;text-align:center;"></div>
</div>
</div>
......
......@@ -56,6 +56,84 @@
/* Transitions */
--ease: cubic-bezier(0.4, 0, 0.2, 1);
/* Board Theme */
--board-light: #E8EDF9;
--board-dark: #7195D1;
--board-selected: rgba(21, 215, 255, 0.45);
--board-legal: rgba(0, 0, 0, 0.18);
--board-last-move: rgba(255, 199, 40, 0.35);
--board-check: rgba(239, 68, 68, 0.55);
--board-premove: rgba(21, 180, 240, 0.3);
--board-highlight-green: rgba(21, 180, 90, 0.4);
--board-highlight-red: rgba(220, 50, 50, 0.4);
--board-highlight-yellow: rgba(220, 180, 30, 0.4);
/* Eval Bar */
--eval-bg: #1a1a2e;
--eval-white: #f0f0f0;
--eval-label-light: #fff;
--eval-label-dark: #333;
/* Move Classifications */
--move-brilliant: #26c6da;
--move-great: #66bb6a;
--move-good: #81c784;
--move-book: #9e9e9e;
--move-inaccuracy: #fdd835;
--move-mistake: #ef6c00;
--move-blunder: #e53935;
/* Analysis Move Text (slightly different palette) */
--move-text-brilliant: #00bcd4;
--move-text-great: #2196f3;
--move-text-good: #4caf50;
--move-text-inaccuracy: #ff9800;
--move-text-mistake: #f44336;
--move-text-blunder: #d32f2f;
/* Arrows */
--arrow-green: rgba(21, 180, 90, 0.7);
--arrow-red: rgba(220, 50, 50, 0.7);
--arrow-yellow: rgba(220, 180, 30, 0.7);
/* Graph / Canvas */
--graph-bg: #0a1628;
--graph-grid: #333;
--graph-accent: #15d7ff;
--graph-error: #f44336;
/* Overlays */
--overlay-dark: rgba(0, 0, 0, 0.8);
--overlay-result: rgba(5, 13, 23, 0.92);
--overlay-error-bg: rgba(239, 68, 68, 0.1);
--overlay-error-border: rgba(239, 68, 68, 0.2);
/* Bot Avatars */
--bot-amina: #4ade80, #22c55e;
--bot-tarek: #38bdf8, #0ea5e9;
--bot-nour: #a78bfa, #7c3aed;
--bot-omar: #fb923c, #ea580c;
--bot-layla: #f472b6, #db2777;
--bot-ziad: #f87171, #dc2626;
--bot-gm: var(--gold), #b45309;
/* Ludo */
--ludo-p1: #E53935;
--ludo-p2: #43A047;
--ludo-p3: #FDD835;
--ludo-p4: #1E88E5;
--ludo-board-bg: #1a2332;
--ludo-path: #f5f5f5;
--ludo-path-border: rgba(0,0,0,0.1);
--ludo-safe: #FFD54F;
--ludo-home-p1: rgba(229,57,53,0.3);
--ludo-home-p2: rgba(67,160,71,0.3);
--ludo-home-p3: rgba(253,216,53,0.3);
--ludo-home-p4: rgba(30,136,229,0.3);
--ludo-dice-bg: var(--bg-2);
--ludo-dice-dot: var(--text-inverse);
--ludo-chat-bg: var(--bg-2);
}
/* Reset */
......@@ -206,7 +284,7 @@ img {
display: flex;
align-items: center;
justify-content: center;
color: #fff;
color: var(--eval-label-light);
}
/* Main content */
......@@ -566,7 +644,7 @@ img {
width: 20px;
height: 20px;
border-radius: var(--radius-full);
background: #fff;
background: var(--eval-label-light);
transition: right 0.2s var(--ease);
}
......
......@@ -69,7 +69,7 @@
.game-clock.low {
background: var(--error);
color: #fff;
color: var(--eval-label-light);
animation: clock-pulse 1s ease infinite;
}
......@@ -137,17 +137,17 @@
transition: background 0.1s;
}
.square-light { background: #E8EDF9; }
.square-dark { background: #7195D1; }
.square-light { background: var(--board-light); }
.square-dark { background: var(--board-dark); }
.square.selected { background: rgba(21, 215, 255, 0.45) !important; }
.square.selected { background: var(--board-selected) !important; }
.square.legal-move::after {
content: '';
position: absolute;
width: 30%;
height: 30%;
border-radius: 50%;
background: rgba(0, 0, 0, 0.18);
background: var(--board-legal);
}
.square.legal-capture::after {
content: '';
......@@ -155,16 +155,16 @@
width: 85%;
height: 85%;
border-radius: 50%;
border: 3.5px solid rgba(0, 0, 0, 0.18);
border: 3.5px solid var(--board-legal);
background: transparent;
}
.square.last-move { background: rgba(255, 199, 40, 0.35) !important; }
.square.in-check { background: rgba(239, 68, 68, 0.55) !important; box-shadow: inset 0 0 8px rgba(239,68,68,0.6); }
.square.premove-from { background: rgba(21, 180, 240, 0.3) !important; }
.square.premove-to { background: rgba(21, 180, 240, 0.3) !important; }
.square.highlight-green { background: rgba(21, 180, 90, 0.4) !important; }
.square.highlight-red { background: rgba(220, 50, 50, 0.4) !important; }
.square.highlight-yellow { background: rgba(220, 180, 30, 0.4) !important; }
.square.last-move { background: var(--board-last-move) !important; }
.square.in-check { background: var(--board-check) !important; box-shadow: inset 0 0 8px var(--board-check); }
.square.premove-from { background: var(--board-premove) !important; }
.square.premove-to { background: var(--board-premove) !important; }
.square.highlight-green { background: var(--board-highlight-green) !important; }
.square.highlight-red { background: var(--board-highlight-red) !important; }
.square.highlight-yellow { background: var(--board-highlight-yellow) !important; }
/* Pieces */
.piece {
......@@ -245,7 +245,7 @@
.eval-bar {
width: 18px;
min-width: 18px;
background: #1a1a2e;
background: var(--eval-bg);
border-radius: var(--radius-sm);
overflow: hidden;
position: relative;
......@@ -258,7 +258,7 @@
bottom: 0;
left: 0;
right: 0;
background: #fff;
background: var(--eval-white);
transition: height 0.5s ease;
height: 50%;
}
......@@ -275,8 +275,8 @@
z-index: 1;
}
.eval-bar-label-top { top: 3px; color: #fff; }
.eval-bar-label-bottom { bottom: 3px; color: #333; }
.eval-bar-label-top { top: 3px; color: var(--eval-label-light); }
.eval-bar-label-bottom { bottom: 3px; color: var(--eval-label-dark); }
/* ============================================
SIDE PANEL
......@@ -486,7 +486,7 @@
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(5, 13, 23, 0.92);
background: var(--overlay-result);
z-index: 10;
border-radius: var(--radius-sm);
gap: 12px;
......@@ -496,7 +496,7 @@
.game-result-title {
font-size: 22px;
font-weight: 700;
color: #fff;
color: var(--text-1);
}
.game-result-subtitle {
......@@ -523,7 +523,7 @@
.game-result-stats .stat-row span:last-child {
font-weight: 600;
color: #fff;
color: var(--text-1);
}
/* ============================================
......@@ -580,12 +580,12 @@
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; }
.move-brilliant { background: var(--move-brilliant); color: var(--eval-label-light); }
.move-great { background: var(--move-great); color: var(--eval-label-light); }
.move-good { background: var(--move-good); color: var(--eval-label-light); }
.move-inaccuracy { background: var(--move-inaccuracy); color: var(--eval-label-dark); }
.move-mistake { background: var(--move-mistake); color: var(--eval-label-light); }
.move-blunder { background: var(--move-blunder); color: var(--eval-label-light); }
/* ============================================
ANALYSIS PAGE
......
/* EL3AB Ludo Game Styles */
/* ============================================
GAME LAYOUT
============================================ */
.ludo-layout {
display: flex;
flex-direction: column;
gap: 0;
max-width: 100%;
direction: ltr;
}
.ludo-board-column {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
/* Player info cards */
.ludo-players-row {
display: flex;
gap: 8px;
width: 100%;
max-width: 480px;
justify-content: space-between;
}
.ludo-player-card {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--radius-md);
flex: 1;
min-width: 0;
transition: all 0.3s var(--ease);
direction: rtl;
}
.ludo-player-card.active {
border-color: var(--gold);
box-shadow: 0 0 12px rgba(231, 168, 50, 0.3);
}
.ludo-player-card.finished {
opacity: 0.5;
}
.ludo-player-dot {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.ludo-player-dot--p1 { background: var(--ludo-p1); }
.ludo-player-dot--p2 { background: var(--ludo-p2); }
.ludo-player-dot--p3 { background: var(--ludo-p3); }
.ludo-player-dot--p4 { background: var(--ludo-p4); }
.ludo-player-name {
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ludo-player-status {
font-size: 10px;
color: var(--text-3);
margin-right: auto;
}
/* ============================================
BOARD
============================================ */
.ludo-board-wrapper {
position: relative;
width: 100%;
max-width: 480px;
aspect-ratio: 1;
}
.ludo-board {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: repeat(15, 1fr);
grid-template-rows: repeat(15, 1fr);
background: var(--ludo-board-bg);
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: var(--shadow-lg);
direction: ltr;
}
/* Path cells */
.ludo-cell {
position: relative;
background: var(--ludo-path);
border: 1px solid var(--ludo-path-border);
display: flex;
align-items: center;
justify-content: center;
}
/* Safe position star */
.ludo-cell--safe::after {
content: '\2605';
font-size: 0.6em;
color: var(--ludo-safe);
opacity: 0.8;
}
/* Start cells (colored) */
.ludo-cell--start-p1 { background: var(--ludo-p1); }
.ludo-cell--start-p2 { background: var(--ludo-p2); }
.ludo-cell--start-p3 { background: var(--ludo-p3); }
.ludo-cell--start-p4 { background: var(--ludo-p4); }
/* Home entrance strips */
.ludo-cell--home-p1 { background: var(--ludo-home-p1); border-color: var(--ludo-p1); }
.ludo-cell--home-p2 { background: var(--ludo-home-p2); border-color: var(--ludo-p2); }
.ludo-cell--home-p3 { background: var(--ludo-home-p3); border-color: var(--ludo-p3); }
.ludo-cell--home-p4 { background: var(--ludo-home-p4); border-color: var(--ludo-p4); }
/* Corner bases */
.ludo-base {
position: relative;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.ludo-base--p1 { grid-area: 10/1 / 16/7; background: var(--ludo-p1); }
.ludo-base--p2 { grid-area: 1/1 / 7/7; background: var(--ludo-p2); }
.ludo-base--p3 { grid-area: 1/10 / 7/16; background: var(--ludo-p3); }
.ludo-base--p4 { grid-area: 10/10 / 16/16; background: var(--ludo-p4); }
/* Inner base circle (where pieces sit initially) */
.ludo-base-inner {
width: 75%;
height: 75%;
background: var(--ludo-board-bg);
border-radius: 50%;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 8%;
padding: 15%;
}
.ludo-base-slot {
width: 100%;
height: 100%;
border-radius: 50%;
background: rgba(255,255,255,0.15);
}
/* Center home (3x3) */
.ludo-center {
grid-area: 7/7 / 10/10;
position: relative;
overflow: hidden;
}
.ludo-center-triangle {
position: absolute;
width: 0;
height: 0;
}
.ludo-center-triangle--p1 {
bottom: 0; left: 50%;
transform: translateX(-50%);
border-left: 50px solid transparent;
border-right: 50px solid transparent;
border-bottom: 50px solid var(--ludo-p1);
}
.ludo-center-triangle--p2 {
left: 0; top: 50%;
transform: translateY(-50%);
border-top: 50px solid transparent;
border-bottom: 50px solid transparent;
border-left: 50px solid var(--ludo-p2);
}
.ludo-center-triangle--p3 {
top: 0; left: 50%;
transform: translateX(-50%);
border-left: 50px solid transparent;
border-right: 50px solid transparent;
border-top: 50px solid var(--ludo-p3);
}
.ludo-center-triangle--p4 {
right: 0; top: 50%;
transform: translateY(-50%);
border-top: 50px solid transparent;
border-bottom: 50px solid transparent;
border-right: 50px solid var(--ludo-p4);
}
/* ============================================
PIECES
============================================ */
.ludo-pieces {
position: absolute;
inset: 0;
pointer-events: none;
}
.ludo-piece {
position: absolute;
width: 4%;
height: 4%;
border-radius: 50%;
border: 2px solid rgba(0,0,0,0.3);
transform: translate(-50%, -50%);
transition: top 0.15s var(--ease), left 0.15s var(--ease);
z-index: 2;
pointer-events: auto;
cursor: default;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.ludo-piece--p1 { background: var(--ludo-p1); }
.ludo-piece--p2 { background: var(--ludo-p2); }
.ludo-piece--p3 { background: var(--ludo-p3); }
.ludo-piece--p4 { background: var(--ludo-p4); }
.ludo-piece--highlight {
cursor: pointer;
animation: ludo-pulse 0.8s ease-in-out infinite;
z-index: 3;
}
.ludo-piece--highlight.ludo-piece--p1 { box-shadow: 0 0 10px var(--ludo-p1), 0 0 20px var(--ludo-p1); }
.ludo-piece--highlight.ludo-piece--p2 { box-shadow: 0 0 10px var(--ludo-p2), 0 0 20px var(--ludo-p2); }
.ludo-piece--highlight.ludo-piece--p3 { box-shadow: 0 0 10px var(--ludo-p3), 0 0 20px var(--ludo-p3); }
.ludo-piece--highlight.ludo-piece--p4 { box-shadow: 0 0 10px var(--ludo-p4), 0 0 20px var(--ludo-p4); }
@keyframes ludo-pulse {
0%, 100% { transform: translate(-50%, -50%) scale(1); }
50% { transform: translate(-50%, -50%) scale(1.3); }
}
.ludo-piece--home {
opacity: 0;
pointer-events: none;
}
/* Stacking offset for pieces on same cell */
.ludo-piece[data-stack="1"] { margin-left: 1.5%; margin-top: -1%; }
.ludo-piece[data-stack="2"] { margin-left: -1.5%; margin-top: 1%; }
.ludo-piece[data-stack="3"] { margin-left: 1.5%; margin-top: 1%; }
/* ============================================
DICE
============================================ */
.ludo-dice-area {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 16px;
}
.ludo-dice {
width: 56px;
height: 56px;
background: var(--ludo-dice-bg);
border: 2px solid var(--border-strong);
border-radius: var(--radius-sm);
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
padding: 8px;
gap: 2px;
transition: transform 0.1s;
}
.ludo-dice--rolling {
animation: ludo-dice-roll 0.4s ease;
}
@keyframes ludo-dice-roll {
0% { transform: rotate(0deg) scale(1); }
25% { transform: rotate(90deg) scale(0.9); }
50% { transform: rotate(180deg) scale(1.1); }
75% { transform: rotate(270deg) scale(0.9); }
100% { transform: rotate(360deg) scale(1); }
}
.ludo-dice-dot {
width: 100%;
height: 100%;
border-radius: 50%;
background: var(--ludo-dice-dot);
display: none;
}
.ludo-dice-dot--visible {
display: block;
}
.ludo-roll-btn {
padding: 10px 28px;
background: var(--gold);
color: var(--text-inverse);
border: none;
border-radius: var(--radius-md);
font-weight: 700;
font-size: 14px;
font-family: var(--font-ar);
cursor: pointer;
transition: all 0.2s var(--ease);
min-width: var(--touch-min);
min-height: var(--touch-min);
}
.ludo-roll-btn:hover { background: var(--gold-dark); }
.ludo-roll-btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* ============================================
SIDE PANEL
============================================ */
.ludo-side-panel {
display: none;
flex-direction: column;
width: 260px;
min-width: 240px;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
}
.ludo-turn-indicator {
padding: 12px 16px;
text-align: center;
font-size: 14px;
font-weight: 600;
border-bottom: 1px solid var(--border);
direction: rtl;
}
.ludo-log {
flex: 1;
overflow-y: auto;
padding: 8px 12px;
font-size: 12px;
color: var(--text-2);
max-height: 200px;
direction: rtl;
}
.ludo-log-entry {
padding: 4px 0;
border-bottom: 1px solid var(--border);
}
.ludo-log-entry:last-child { border-bottom: none; }
.ludo-controls {
display: flex;
gap: 6px;
padding: 10px 12px;
border-top: 1px solid var(--border);
direction: rtl;
}
/* ============================================
CHAT
============================================ */
.ludo-chat {
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
max-height: 200px;
}
.ludo-chat-header {
padding: 8px 12px;
font-size: 12px;
font-weight: 600;
border-bottom: 1px solid var(--border);
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
direction: rtl;
}
.ludo-chat-messages {
flex: 1;
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
gap: 4px;
max-height: 140px;
}
.ludo-chat-msg {
font-size: 12px;
padding: 4px 8px;
border-radius: 6px;
background: var(--bg-3);
direction: rtl;
}
.ludo-chat-msg--system {
color: var(--text-3);
font-style: italic;
background: none;
text-align: center;
}
.ludo-chat-sender {
font-weight: 600;
margin-left: 4px;
}
.ludo-chat-input-row {
display: flex;
gap: 4px;
padding: 8px;
border-top: 1px solid var(--border);
direction: rtl;
}
.ludo-chat-input {
flex: 1;
padding: 6px 10px;
background: var(--bg-3);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-1);
font-size: 12px;
font-family: var(--font-ar);
}
.ludo-chat-send {
padding: 6px 12px;
background: var(--cyan);
color: var(--text-inverse);
border: none;
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.ludo-quick-msgs {
display: flex;
gap: 4px;
padding: 4px 8px;
overflow-x: auto;
direction: rtl;
}
.ludo-quick-msg {
padding: 4px 8px;
background: var(--bg-3);
border: 1px solid var(--border);
border-radius: var(--radius-full);
font-size: 11px;
white-space: nowrap;
cursor: pointer;
color: var(--text-2);
transition: background 0.15s;
}
.ludo-quick-msg:hover { background: var(--bg-2); color: var(--text-1); }
/* ============================================
GAME RESULT OVERLAY
============================================ */
.ludo-result {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--overlay-result);
z-index: 10;
border-radius: var(--radius-md);
gap: 12px;
direction: rtl;
}
.ludo-result-title {
font-size: 24px;
font-weight: 700;
color: var(--gold);
}
.ludo-result-subtitle {
font-size: 14px;
color: var(--text-2);
}
.ludo-result-actions {
display: flex;
gap: 8px;
margin-top: 16px;
}
/* ============================================
MOBILE PANEL
============================================ */
.ludo-mobile-panel {
display: none;
flex-direction: column;
width: 100%;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
}
/* Mobile chat toggle */
.ludo-chat-toggle {
position: fixed;
bottom: calc(var(--nav-bottom-h) + 12px);
left: 12px;
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--cyan);
color: var(--text-inverse);
border: none;
display: none;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: var(--shadow-md);
z-index: 20;
}
/* ============================================
RESPONSIVE
============================================ */
@media (min-width: 768px) {
.ludo-layout {
flex-direction: row;
align-items: flex-start;
gap: 16px;
justify-content: center;
padding: 8px 0;
}
.ludo-board-column {
flex: 0 0 auto;
width: min(480px, calc(100vh - 180px));
}
.ludo-side-panel {
display: flex;
flex: 0 1 280px;
max-height: min(480px, calc(100vh - 180px));
}
.ludo-mobile-panel { display: none; }
}
@media (max-width: 767px) {
.ludo-layout {
flex-direction: column;
align-items: center;
padding: 0;
}
.ludo-board-column { width: 100%; }
.ludo-board-wrapper { max-width: 100%; }
.ludo-side-panel { display: none; }
.ludo-mobile-panel {
display: flex;
margin-top: 8px;
}
.ludo-chat-toggle { display: flex; }
.ludo-players-row { max-width: 100%; }
}
......@@ -159,4 +159,33 @@
<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>
<symbol id="icon-ludo" viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8" cy="8" r="2"/>
<circle cx="16" cy="8" r="2"/>
<circle cx="8" cy="16" r="2"/>
<circle cx="16" cy="16" r="2"/>
<path d="M12 3v18M3 12h18"/>
</symbol>
<symbol id="icon-dice" viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="3"/>
<circle cx="8" cy="8" r="1.5" fill="currentColor" stroke="none"/>
<circle cx="16" cy="8" r="1.5" fill="currentColor" stroke="none"/>
<circle cx="8" cy="16" r="1.5" fill="currentColor" stroke="none"/>
<circle cx="16" cy="16" r="1.5" fill="currentColor" stroke="none"/>
<circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none"/>
</symbol>
<symbol id="icon-users" viewBox="0 0 24 24">
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/>
</symbol>
<symbol id="icon-lock" viewBox="0 0 24 24">
<rect x="3" y="11" width="18" height="11" rx="2"/>
<path d="M7 11V7a5 5 0 0110 0v4"/>
</symbol>
</svg>
......@@ -431,12 +431,14 @@ const Analysis = {
const midY = H / 2;
const maxEval = 5;
const style = getComputedStyle(document.documentElement);
// Background
ctx.fillStyle = '#0a1628';
ctx.fillStyle = style.getPropertyValue('--graph-bg').trim();
ctx.fillRect(0, 0, W, H);
// Center line (0 eval)
ctx.strokeStyle = '#333';
ctx.strokeStyle = style.getPropertyValue('--graph-grid').trim();
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, midY);
......@@ -473,7 +475,7 @@ const Analysis = {
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.strokeStyle = '#15d7ff';
ctx.strokeStyle = style.getPropertyValue('--graph-accent').trim();
ctx.lineWidth = 2;
ctx.stroke();
......@@ -485,7 +487,7 @@ const Analysis = {
const y = midY - (clamped / maxEval) * (midY - 10);
ctx.beginPath();
ctx.arc(x, y, 4, 0, Math.PI * 2);
ctx.fillStyle = '#f44336';
ctx.fillStyle = style.getPropertyValue('--graph-error').trim();
ctx.fill();
}
}
......@@ -512,7 +514,8 @@ const Analysis = {
const H = canvas.height;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#0a1628';
const style2 = getComputedStyle(document.documentElement);
ctx.fillStyle = style2.getPropertyValue('--graph-bg').trim();
ctx.fillRect(0, 0, W, H);
// If no move time data available, show message
......
......@@ -156,6 +156,7 @@ const Board = {
updatePieces() {
if (!this.position) return;
const board = this.position.board();
const assets = window.__themeAssets || {};
this.squares.forEach(sq => {
const sqName = sq.dataset.square;
......@@ -163,7 +164,7 @@ const Board = {
const rank = 8 - parseInt(sqName[1]);
const piece = board[rank][file];
let pieceEl = sq.querySelector('.piece');
let pieceEl = sq.querySelector('.piece:not(.piece-ghost)');
if (piece) {
const key = piece.color + piece.type.toUpperCase();
if (!pieceEl) {
......@@ -171,7 +172,21 @@ const Board = {
pieceEl.className = 'piece';
sq.appendChild(pieceEl);
}
pieceEl.textContent = this.PIECES[key];
const customSrc = assets['piece-' + key];
if (customSrc) {
pieceEl.textContent = '';
let img = pieceEl.querySelector('img');
if (!img) {
img = document.createElement('img');
img.style.cssText = 'width:100%;height:100%;object-fit:contain;pointer-events:none;';
pieceEl.appendChild(img);
}
img.src = customSrc;
} else {
const img = pieceEl.querySelector('img');
if (img) img.remove();
pieceEl.textContent = this.PIECES[key];
}
pieceEl.dataset.piece = key;
pieceEl.dataset.color = piece.color;
} else {
......@@ -270,7 +285,13 @@ const Board = {
const key = piece.color + piece.type.toUpperCase();
const ghost = document.createElement('div');
ghost.className = 'piece piece-ghost';
ghost.textContent = this.PIECES[key];
const assets = window.__themeAssets || {};
const customSrc = assets['piece-' + key];
if (customSrc) {
ghost.innerHTML = '<img src="' + customSrc + '" style="width:100%;height:100%;object-fit:contain;pointer-events:none;">';
} else {
ghost.textContent = this.PIECES[key];
}
ghost.style.opacity = '0.4';
ghost.style.pointerEvents = 'none';
toSq.appendChild(ghost);
......@@ -572,10 +593,11 @@ const Board = {
const h = this.arrowCanvas.height;
ctx.clearRect(0, 0, w, h);
const style = getComputedStyle(document.documentElement);
const colorMap = {
green: 'rgba(21, 180, 90, 0.7)',
red: 'rgba(220, 50, 50, 0.7)',
yellow: 'rgba(220, 180, 30, 0.7)'
green: style.getPropertyValue('--arrow-green').trim() || 'rgba(21, 180, 90, 0.7)',
red: style.getPropertyValue('--arrow-red').trim() || 'rgba(220, 50, 50, 0.7)',
yellow: style.getPropertyValue('--arrow-yellow').trim() || 'rgba(220, 180, 30, 0.7)'
};
const allArrows = [...this.arrows];
......@@ -695,9 +717,9 @@ const Board = {
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();
const assets = window.__themeAssets || {};
this.squares.forEach(sq => {
const sqName = sq.dataset.square;
......@@ -713,7 +735,21 @@ const Board = {
pieceEl.className = 'piece';
sq.appendChild(pieceEl);
}
pieceEl.textContent = this.PIECES[key];
const customSrc = assets['piece-' + key];
if (customSrc) {
pieceEl.textContent = '';
let img = pieceEl.querySelector('img');
if (!img) {
img = document.createElement('img');
img.style.cssText = 'width:100%;height:100%;object-fit:contain;pointer-events:none;';
pieceEl.appendChild(img);
}
img.src = customSrc;
} else {
const img = pieceEl.querySelector('img');
if (img) img.remove();
pieceEl.textContent = this.PIECES[key];
}
pieceEl.dataset.piece = key;
pieceEl.dataset.color = piece.color;
} else {
......@@ -721,7 +757,6 @@ const Board = {
}
});
// Disable interaction if not viewing latest position
this.enabled = (index === this.positionHistory.length - 1);
},
......@@ -757,10 +792,17 @@ const Board = {
n: color === 'w' ? '♘' : '♞'
};
const assets = window.__themeAssets || {};
pieces.forEach(p => {
const btn = document.createElement('div');
btn.className = 'promotion-piece';
btn.textContent = symbols[p];
const key = color + p.toUpperCase();
const customSrc = assets['piece-' + key];
if (customSrc) {
btn.innerHTML = '<img src="' + customSrc + '" style="width:100%;height:100%;object-fit:contain;">';
} else {
btn.textContent = symbols[p];
}
btn.onclick = () => {
overlay.remove();
this.confirmMove({ from: move.from, to: move.to, promotion: p });
......
var LudoBot = (function() {
'use strict';
var C = LudoConstants;
function getEligiblePieces(player, diceValue, positions, activePlayers) {
var eligible = [];
var playerPositions = positions[player];
for (var i = 0; i < 4; i++) {
var pos = playerPositions[i];
var isInBase = C.BASE_POSITIONS[player].indexOf(pos) !== -1;
var isHome = pos === C.HOME_POSITIONS[player];
if (isHome) continue;
if (isInBase) {
if (diceValue === 6) eligible.push(i);
continue;
}
var newPos = calculateNewPosition(player, pos, diceValue);
if (newPos !== null) eligible.push(i);
}
return eligible;
}
function calculateNewPosition(player, currentPos, diceValue) {
var isInHomeEntrance = C.HOME_ENTRANCE[player].indexOf(currentPos) !== -1;
if (isInHomeEntrance) {
var homeIdx = C.HOME_ENTRANCE[player].indexOf(currentPos);
var stepsLeft = 5 - homeIdx;
if (diceValue > stepsLeft) return null;
if (diceValue === stepsLeft) return C.HOME_POSITIONS[player];
return C.HOME_ENTRANCE[player][homeIdx + diceValue];
}
var turningPoint = C.TURNING_POINTS[player];
var startPos = C.START_POSITIONS[player];
var distanceToTurn = (turningPoint - currentPos + 52) % 52;
if (distanceToTurn === 0 && diceValue <= 6) {
if (diceValue <= 5) return C.HOME_ENTRANCE[player][diceValue - 1];
return null;
}
if (diceValue > distanceToTurn && distanceToTurn > 0 && distanceToTurn < 6) {
var overflow = diceValue - distanceToTurn;
if (overflow <= 5) return C.HOME_ENTRANCE[player][overflow - 1];
return null;
}
var newPos = (currentPos + diceValue) % 52;
return newPos;
}
function choosePieceEasy(eligible) {
if (eligible.length === 0) return -1;
return eligible[Math.floor(Math.random() * eligible.length)];
}
function choosePieceHard(player, diceValue, positions, activePlayers, eligible) {
if (eligible.length === 0) return -1;
if (eligible.length === 1) return eligible[0];
var bestScore = -Infinity;
var bestPiece = eligible[0];
eligible.forEach(function(pieceIdx) {
var score = scorePieceMove(player, pieceIdx, diceValue, positions, activePlayers);
if (score > bestScore) {
bestScore = score;
bestPiece = pieceIdx;
}
});
return bestPiece;
}
function scorePieceMove(player, pieceIdx, diceValue, positions, activePlayers) {
var score = 0;
var currentPos = positions[player][pieceIdx];
var isInBase = C.BASE_POSITIONS[player].indexOf(currentPos) !== -1;
if (isInBase && diceValue === 6) {
score += 30;
var startPos = C.START_POSITIONS[player];
if (canKillAt(player, startPos, positions, activePlayers)) {
score += 50;
}
return score;
}
var newPos = calculateNewPosition(player, currentPos, diceValue);
if (newPos === null) return -Infinity;
if (newPos === C.HOME_POSITIONS[player]) {
score += 100;
return score;
}
var isEnteringHome = C.HOME_ENTRANCE[player].indexOf(newPos) !== -1;
if (isEnteringHome) {
score += 25;
}
if (newPos >= 0 && newPos <= 51) {
if (canKillAt(player, newPos, positions, activePlayers)) {
score += 50;
}
if (C.SAFE_POSITIONS.indexOf(newPos) !== -1) {
score += 20;
} else if (isInDanger(player, newPos, positions, activePlayers)) {
score -= 15;
}
if (isBlockingOpponentExit(player, newPos, positions, activePlayers)) {
score += 15;
}
}
var progress = getProgressDistance(player, currentPos);
var maxProgress = 0;
positions[player].forEach(function(pos, idx) {
if (idx === pieceIdx) return;
if (C.BASE_POSITIONS[player].indexOf(pos) !== -1) return;
if (pos === C.HOME_POSITIONS[player]) return;
var p = getProgressDistance(player, pos);
if (p > maxProgress) maxProgress = p;
});
if (progress < maxProgress) {
score += 10;
}
return score;
}
function canKillAt(player, position, positions, activePlayers) {
if (C.SAFE_POSITIONS.indexOf(position) !== -1) return false;
for (var i = 0; i < activePlayers.length; i++) {
var opponent = activePlayers[i];
if (opponent === player) continue;
for (var j = 0; j < 4; j++) {
if (positions[opponent][j] === position) return true;
}
}
return false;
}
function isInDanger(player, position, positions, activePlayers) {
for (var i = 0; i < activePlayers.length; i++) {
var opponent = activePlayers[i];
if (opponent === player) continue;
for (var j = 0; j < 4; j++) {
var oppPos = positions[opponent][j];
if (C.BASE_POSITIONS[opponent].indexOf(oppPos) !== -1) continue;
if (oppPos === C.HOME_POSITIONS[opponent]) continue;
if (C.HOME_ENTRANCE[opponent].indexOf(oppPos) !== -1) continue;
for (var dice = 1; dice <= 6; dice++) {
var oppNewPos = (oppPos + dice) % 52;
if (oppNewPos === position) return true;
}
}
}
return false;
}
function isBlockingOpponentExit(player, position, positions, activePlayers) {
for (var i = 0; i < activePlayers.length; i++) {
var opponent = activePlayers[i];
if (opponent === player) continue;
if (position === C.START_POSITIONS[opponent]) return true;
}
return false;
}
function getProgressDistance(player, position) {
if (C.BASE_POSITIONS[player].indexOf(position) !== -1) return 0;
if (position === C.HOME_POSITIONS[player]) return 57;
var homeIdx = C.HOME_ENTRANCE[player].indexOf(position);
if (homeIdx !== -1) return 52 + homeIdx;
var start = C.START_POSITIONS[player];
return (position - start + 52) % 52;
}
function takeTurn(player, diceValue, positions, activePlayers, difficulty) {
var eligible = getEligiblePieces(player, diceValue, positions, activePlayers);
if (eligible.length === 0) return -1;
if (difficulty === 'hard') {
return choosePieceHard(player, diceValue, positions, activePlayers, eligible);
}
return choosePieceEasy(eligible);
}
return {
takeTurn: takeTurn,
getEligiblePieces: getEligiblePieces,
calculateNewPosition: calculateNewPosition
};
})();
var LudoChat = (function() {
'use strict';
var C = LudoConstants;
var containerEl = null;
var messagesEl = null;
var inputEl = null;
var quickEl = null;
var matchId = null;
var lastSentAt = 0;
var COOLDOWN = 2000;
function init(container, matchIdVal, onSend) {
matchId = matchIdVal;
containerEl = typeof container === 'string' ? document.querySelector(container) : container;
if (!containerEl) return;
containerEl.innerHTML = '';
containerEl.className = 'ludo-chat';
messagesEl = document.createElement('div');
messagesEl.className = 'ludo-chat-messages';
quickEl = document.createElement('div');
quickEl.className = 'ludo-chat-quick';
C.QUICK_MESSAGES.forEach(function(msg) {
var btn = document.createElement('button');
btn.className = 'ludo-chat-quick-btn';
btn.textContent = msg;
btn.addEventListener('click', function() {
send(msg, onSend);
});
quickEl.appendChild(btn);
});
var inputRow = document.createElement('div');
inputRow.className = 'ludo-chat-input-row';
inputEl = document.createElement('input');
inputEl.type = 'text';
inputEl.className = 'ludo-chat-input';
inputEl.placeholder = 'اكتب رسالة...';
inputEl.maxLength = 100;
var sendBtn = document.createElement('button');
sendBtn.className = 'ludo-chat-send-btn';
sendBtn.textContent = 'ارسل';
sendBtn.addEventListener('click', function() {
var text = inputEl.value.trim();
if (text) {
send(text, onSend);
inputEl.value = '';
}
});
inputEl.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
var text = inputEl.value.trim();
if (text) {
send(text, onSend);
inputEl.value = '';
}
}
});
inputRow.appendChild(inputEl);
inputRow.appendChild(sendBtn);
containerEl.appendChild(messagesEl);
containerEl.appendChild(quickEl);
containerEl.appendChild(inputRow);
}
function send(text, onSend) {
var now = Date.now();
if (now - lastSentAt < COOLDOWN) return;
lastSentAt = now;
if (onSend) onSend(text);
}
function addMessage(senderName, text, isSystem) {
if (!messagesEl) return;
var msg = document.createElement('div');
msg.className = 'ludo-chat-msg';
if (isSystem) msg.classList.add('ludo-chat-msg--system');
if (!isSystem && senderName) {
var nameEl = document.createElement('span');
nameEl.className = 'ludo-chat-msg-name';
nameEl.textContent = senderName;
msg.appendChild(nameEl);
}
var textEl = document.createElement('span');
textEl.className = 'ludo-chat-msg-text';
textEl.textContent = text;
msg.appendChild(textEl);
messagesEl.appendChild(msg);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function loadMessages(chatArray) {
if (!messagesEl) return;
messagesEl.innerHTML = '';
chatArray.forEach(function(m) {
addMessage(m.sender_name, m.text, false);
});
}
function addSystemMessage(text) {
addMessage(null, text, true);
}
return {
init: init,
addMessage: addMessage,
addSystemMessage: addSystemMessage,
loadMessages: loadMessages
};
})();
var LudoConstants = (function() {
'use strict';
var STEP_LENGTH = 6.66;
var PLAYERS = ['P1', 'P2', 'P3', 'P4'];
var PLAYER_LABELS = { P1: 'اللاعب 1', P2: 'اللاعب 2', P3: 'اللاعب 3', P4: 'اللاعب 4' };
var PLAYER_COLORS = { P1: 'p1', P2: 'p2', P3: 'p3', P4: 'p4' };
// Main track: 52 positions (0-51) going clockwise
// Grid is 15x15, coordinates are [col, row] (0-indexed)
// Position 0 starts at bottom of left column (P1's start)
var COORDINATES_MAP = {
// Left column going UP (positions 0-5)
0: [6, 13],
1: [6, 12],
2: [6, 11],
3: [6, 10],
4: [6, 9],
// Top of left cross arm (positions 5-12)
5: [5, 8],
6: [4, 8],
7: [3, 8],
8: [2, 8],
9: [1, 8],
10: [0, 8],
11: [0, 7],
12: [0, 6],
// Top row going RIGHT (positions 13-18)
13: [1, 6],
14: [2, 6],
15: [3, 6],
16: [4, 6],
17: [5, 6],
// Top column going UP (positions 18-25)
18: [6, 5],
19: [6, 4],
20: [6, 3],
21: [6, 2],
22: [6, 1],
23: [6, 0],
24: [7, 0],
25: [8, 0],
// Right column going DOWN (positions 26-30)
26: [8, 1],
27: [8, 2],
28: [8, 3],
29: [8, 4],
30: [8, 5],
// Right cross arm (positions 31-38)
31: [9, 6],
32: [10, 6],
33: [11, 6],
34: [12, 6],
35: [13, 6],
36: [14, 6],
37: [14, 7],
38: [14, 8],
// Bottom row going LEFT (positions 39-43)
39: [13, 8],
40: [12, 8],
41: [11, 8],
42: [10, 8],
43: [9, 8],
// Bottom column going DOWN (positions 44-51)
44: [8, 9],
45: [8, 10],
46: [8, 11],
47: [8, 12],
48: [8, 13],
49: [8, 14],
50: [7, 14],
51: [6, 14],
// P1 Home Entrance (bottom, going UP along center column 7)
100: [7, 13],
101: [7, 12],
102: [7, 11],
103: [7, 10],
104: [7, 9],
105: [7, 8],
// P2 Home Entrance (left, going RIGHT along center row 7)
200: [1, 7],
201: [2, 7],
202: [3, 7],
203: [4, 7],
204: [5, 7],
205: [6, 7],
// P3 Home Entrance (top, going DOWN along center column 7)
300: [7, 1],
301: [7, 2],
302: [7, 3],
303: [7, 4],
304: [7, 5],
305: [7, 6],
// P4 Home Entrance (right, going LEFT along center row 7)
400: [13, 7],
401: [12, 7],
402: [11, 7],
403: [10, 7],
404: [9, 7],
405: [8, 7],
// P1 Base positions (bottom-left quadrant)
500: [1.5, 10.6],
501: [3.5, 10.6],
502: [1.5, 12.4],
503: [3.5, 12.4],
// P2 Base positions (top-left quadrant)
600: [1.5, 1.6],
601: [3.5, 1.6],
602: [1.5, 3.4],
603: [3.5, 3.4],
// P3 Base positions (top-right quadrant)
700: [10.5, 1.6],
701: [12.5, 1.6],
702: [10.5, 3.4],
703: [12.5, 3.4],
// P4 Base positions (bottom-right quadrant)
800: [10.5, 10.6],
801: [12.5, 10.6],
802: [10.5, 12.4],
803: [12.5, 12.4]
};
var BASE_POSITIONS = {
P1: [500, 501, 502, 503],
P2: [600, 601, 602, 603],
P3: [700, 701, 702, 703],
P4: [800, 801, 802, 803]
};
var START_POSITIONS = {
P1: 0,
P2: 13,
P3: 26,
P4: 39
};
var HOME_ENTRANCE = {
P1: [100, 101, 102, 103, 104],
P2: [200, 201, 202, 203, 204],
P3: [300, 301, 302, 303, 304],
P4: [400, 401, 402, 403, 404]
};
var HOME_POSITIONS = {
P1: 105,
P2: 205,
P3: 305,
P4: 405
};
// The last main-track position before entering home entrance
var TURNING_POINTS = {
P1: 50,
P2: 11,
P3: 24,
P4: 37
};
// Positions where pieces cannot be killed
var SAFE_POSITIONS = [0, 8, 13, 21, 26, 34, 39, 47];
var STATE = {
DICE_NOT_ROLLED: 'DICE_NOT_ROLLED',
DICE_ROLLED: 'DICE_ROLLED',
PIECE_MOVING: 'PIECE_MOVING'
};
// Dice face dot positions (3x3 grid indexed 0-8)
// 0 1 2
// 3 4 5
// 6 7 8
var DICE_FACES = {
1: [4],
2: [2, 6],
3: [2, 4, 6],
4: [0, 2, 6, 8],
5: [0, 2, 4, 6, 8],
6: [0, 2, 3, 5, 6, 8]
};
// Quick chat messages
var QUICK_MESSAGES = [
'حظ سعيد',
'يلا',
'هههه',
'اخخخ',
'GG',
'شكرا',
'يا سلام!',
'مبروك'
];
return {
STEP_LENGTH: STEP_LENGTH,
PLAYERS: PLAYERS,
PLAYER_LABELS: PLAYER_LABELS,
PLAYER_COLORS: PLAYER_COLORS,
COORDINATES_MAP: COORDINATES_MAP,
BASE_POSITIONS: BASE_POSITIONS,
START_POSITIONS: START_POSITIONS,
HOME_ENTRANCE: HOME_ENTRANCE,
HOME_POSITIONS: HOME_POSITIONS,
TURNING_POINTS: TURNING_POINTS,
SAFE_POSITIONS: SAFE_POSITIONS,
STATE: STATE,
DICE_FACES: DICE_FACES,
QUICK_MESSAGES: QUICK_MESSAGES
};
})();
var LudoGame = (function() {
'use strict';
var C = LudoConstants;
var UI = LudoUI;
var Bot = LudoBot;
var state = {
activePlayers: [],
positions: {},
currentPlayerIdx: 0,
diceValue: 0,
phase: C.STATE.DICE_NOT_ROLLED,
winners: [],
consecutiveSixes: 0,
isGameOver: false,
mode: 'local',
bots: {},
botDifficulty: 'easy',
playerNames: {},
onGameEnd: null
};
function init(options) {
state.activePlayers = options.players || ['P1', 'P2', 'P3', 'P4'];
state.mode = options.mode || 'local';
state.bots = options.bots || {};
state.botDifficulty = options.difficulty || 'easy';
state.playerNames = options.playerNames || {};
state.onGameEnd = options.onGameEnd || null;
state.currentPlayerIdx = 0;
state.diceValue = 0;
state.phase = C.STATE.DICE_NOT_ROLLED;
state.winners = [];
state.consecutiveSixes = 0;
state.isGameOver = false;
state.positions = {};
state.activePlayers.forEach(function(p) {
state.positions[p] = C.BASE_POSITIONS[p].slice();
});
UI.renderPieces(state.activePlayers);
state.activePlayers.forEach(function(p) {
for (var i = 0; i < 4; i++) {
UI.setPiecePosition(p, i, state.positions[p][i]);
}
});
UI.setTurn(currentPlayer(), state.playerNames[currentPlayer()]);
UI.enableDice();
UI.clearLog();
UI.hideResult();
UI.addLogEntry('بدأت اللعبة!');
UI.listenDiceClick(onDiceClick);
UI.listenPieceClick(onPieceClick);
if (isCurrentPlayerBot()) {
scheduleBotRoll();
}
}
function currentPlayer() {
return state.activePlayers[state.currentPlayerIdx];
}
function isCurrentPlayerBot() {
return !!state.bots[currentPlayer()];
}
function onDiceClick() {
if (state.isGameOver) return;
if (state.phase !== C.STATE.DICE_NOT_ROLLED) return;
if (isCurrentPlayerBot()) return;
rollDice();
}
function rollDice() {
state.phase = C.STATE.DICE_ROLLED;
UI.disableDice();
var value = 1 + Math.floor(Math.random() * 6);
state.diceValue = value;
UI.animateDiceRoll(value, function() {
afterRoll();
});
}
function afterRoll() {
var player = currentPlayer();
var eligible = Bot.getEligiblePieces(player, state.diceValue, state.positions, state.activePlayers);
UI.addLogEntry(getPlayerLabel(player) + ' رمى ' + state.diceValue);
if (eligible.length === 0) {
UI.addLogEntry(getPlayerLabel(player) + ' لا يوجد حركة متاحة');
state.consecutiveSixes = 0;
setTimeout(function() { nextTurn(); }, 600);
return;
}
if (eligible.length === 1 && isCurrentPlayerBot()) {
setTimeout(function() { movePiece(player, eligible[0]); }, 500);
return;
}
if (isCurrentPlayerBot()) {
var chosen = Bot.takeTurn(player, state.diceValue, state.positions, state.activePlayers,
state.bots[player] || state.botDifficulty);
setTimeout(function() { movePiece(player, chosen); }, 500);
return;
}
UI.highlightPieces(player, eligible);
state.phase = C.STATE.DICE_ROLLED;
}
function onPieceClick(e) {
if (state.isGameOver) return;
if (state.phase !== C.STATE.DICE_ROLLED) return;
if (isCurrentPlayerBot()) return;
var target = e.target.closest('.ludo-piece--highlight');
if (!target) return;
var player = target.dataset.player;
var pieceIdx = parseInt(target.dataset.piece, 10);
if (player !== currentPlayer()) return;
UI.unhighlightPieces();
movePiece(player, pieceIdx);
}
function movePiece(player, pieceIdx) {
state.phase = C.STATE.PIECE_MOVING;
UI.unhighlightPieces();
var currentPos = state.positions[player][pieceIdx];
var isInBase = C.BASE_POSITIONS[player].indexOf(currentPos) !== -1;
var newPos;
if (isInBase && state.diceValue === 6) {
newPos = C.START_POSITIONS[player];
} else {
newPos = Bot.calculateNewPosition(player, currentPos, state.diceValue);
}
if (newPos === null) {
nextTurn();
return;
}
state.positions[player][pieceIdx] = newPos;
UI.setPiecePosition(player, pieceIdx, newPos);
UI.updateStacking(state.positions, state.activePlayers);
var killed = checkKill(player, pieceIdx, newPos);
var reachedHome = newPos === C.HOME_POSITIONS[player];
var rolledSix = state.diceValue === 6;
var extraTurn = false;
if (killed) {
UI.addLogEntry(getPlayerLabel(player) + ' اكل قطعة ' + getPlayerLabel(killed.player));
extraTurn = true;
}
if (reachedHome) {
UI.addLogEntry(getPlayerLabel(player) + ' وصّل قطعة للبيت!');
extraTurn = true;
if (checkPlayerFinished(player)) {
state.winners.push(player);
UI.setPlayerFinished(player);
UI.addLogEntry(getPlayerLabel(player) + ' خلّص! المركز ' + state.winners.length);
if (checkGameOver()) {
endGame();
return;
}
}
}
if (rolledSix) {
state.consecutiveSixes++;
if (state.consecutiveSixes >= 3) {
UI.addLogEntry(getPlayerLabel(player) + ' 3 ستات متتالية - ضاع الدور');
state.consecutiveSixes = 0;
extraTurn = false;
} else {
extraTurn = true;
}
}
if (extraTurn && !checkPlayerFinished(player)) {
state.phase = C.STATE.DICE_NOT_ROLLED;
UI.enableDice();
if (isCurrentPlayerBot()) {
scheduleBotRoll();
}
} else {
state.consecutiveSixes = 0;
setTimeout(function() { nextTurn(); }, 300);
}
}
function checkKill(player, pieceIdx, position) {
if (C.SAFE_POSITIONS.indexOf(position) !== -1) return null;
if (position === C.START_POSITIONS[player]) {
// can still kill at start
}
if (C.HOME_ENTRANCE[player].indexOf(position) !== -1) return null;
if (position === C.HOME_POSITIONS[player]) return null;
for (var i = 0; i < state.activePlayers.length; i++) {
var opponent = state.activePlayers[i];
if (opponent === player) continue;
for (var j = 0; j < 4; j++) {
if (state.positions[opponent][j] === position) {
state.positions[opponent][j] = C.BASE_POSITIONS[opponent][j];
UI.setPiecePosition(opponent, j, C.BASE_POSITIONS[opponent][j]);
UI.updateStacking(state.positions, state.activePlayers);
return { player: opponent, piece: j };
}
}
}
return null;
}
function checkPlayerFinished(player) {
for (var i = 0; i < 4; i++) {
if (state.positions[player][i] !== C.HOME_POSITIONS[player]) return false;
}
return true;
}
function checkGameOver() {
var remaining = state.activePlayers.filter(function(p) {
return state.winners.indexOf(p) === -1;
});
return remaining.length <= 1;
}
function endGame() {
state.isGameOver = true;
UI.disableDice();
var remaining = state.activePlayers.filter(function(p) {
return state.winners.indexOf(p) === -1;
});
remaining.forEach(function(p) { state.winners.push(p); });
var winner = state.winners[0];
var title = getPlayerLabel(winner) + ' فاز!';
var subtitle = 'ترتيب: ' + state.winners.map(function(p, i) {
return (i + 1) + '. ' + getPlayerLabel(p);
}).join(' | ');
UI.showResult(title, subtitle);
UI.addLogEntry('انتهت اللعبة! الفائز: ' + getPlayerLabel(winner));
if (state.onGameEnd) {
state.onGameEnd(state.winners);
}
}
function nextTurn() {
var startIdx = state.currentPlayerIdx;
do {
state.currentPlayerIdx = (state.currentPlayerIdx + 1) % state.activePlayers.length;
} while (
state.winners.indexOf(state.activePlayers[state.currentPlayerIdx]) !== -1 &&
state.currentPlayerIdx !== startIdx
);
if (state.currentPlayerIdx === startIdx && state.winners.indexOf(currentPlayer()) !== -1) {
endGame();
return;
}
state.phase = C.STATE.DICE_NOT_ROLLED;
state.consecutiveSixes = 0;
UI.setTurn(currentPlayer(), state.playerNames[currentPlayer()]);
UI.enableDice();
if (isCurrentPlayerBot()) {
scheduleBotRoll();
}
}
function scheduleBotRoll() {
UI.disableDice();
setTimeout(function() {
if (state.isGameOver) return;
rollDice();
}, 800);
}
function getPlayerLabel(player) {
return state.playerNames[player] || C.PLAYER_LABELS[player];
}
function getState() {
return {
positions: state.positions,
currentPlayer: currentPlayer(),
diceValue: state.diceValue,
phase: state.phase,
winners: state.winners,
isGameOver: state.isGameOver
};
}
function restart(options) {
init(options);
}
return {
init: init,
restart: restart,
getState: getState
};
})();
var LudoLive = (function() {
'use strict';
var C = LudoConstants;
var UI = LudoUI;
var Chat = LudoChat;
var state = {
matchId: null,
userId: null,
token: null,
match: null,
myColor: null,
connected: false,
reconnecting: false
};
var LUDO_RT_ENDPOINT = 'wss://safe-supabase-kong.caprover.al-arcade.com/realtime/v1/websocket';
var ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjE4OTM0NTYwMDB9.31PF6PvP-pSrvRuQwLFptQoejR0W1A7o53lZhEbnz84';
var ws = null;
var topic = null;
var heartbeatInterval = null;
var heartbeatRef = 0;
var awaitingHeartbeat = false;
var reconnectAttempts = 0;
var reconnectTimer = null;
function init(options) {
state.matchId = options.matchId;
state.userId = options.userId;
state.token = options.token;
connect();
fetchState();
}
function connect() {
if (ws) { ws.onclose = null; ws.close(); }
var url = LUDO_RT_ENDPOINT + '?apikey=' + ANON_KEY + '&vsn=1.0.0';
ws = new WebSocket(url);
ws.onopen = function() {
state.connected = true;
state.reconnecting = false;
reconnectAttempts = 0;
startHeartbeat();
joinChannel();
};
ws.onmessage = function(evt) {
handleMessage(evt);
};
ws.onclose = function() {
state.connected = false;
stopHeartbeat();
scheduleReconnect();
};
ws.onerror = function() {};
}
function joinChannel() {
topic = 'realtime:public:ludo_matches:id=eq.' + state.matchId;
send({
topic: topic,
event: 'phx_join',
payload: {
config: {
broadcast: { self: false },
presence: { key: '' },
postgres_changes: [{
event: 'UPDATE',
schema: 'public',
table: 'ludo_matches',
filter: 'id=eq.' + state.matchId
}]
},
access_token: state.token
},
ref: '1'
});
}
function handleMessage(evt) {
var msg;
try { msg = JSON.parse(evt.data); } catch(e) { return; }
if (msg.topic === 'phoenix' && msg.event === 'phx_reply') {
awaitingHeartbeat = false;
return;
}
if (msg.event === 'postgres_changes' && msg.topic === topic) {
var payload = msg.payload;
if (payload && payload.data && payload.data.type === 'UPDATE' && payload.data.record) {
onMatchUpdate(payload.data.record);
}
}
}
function startHeartbeat() {
stopHeartbeat();
heartbeatInterval = setInterval(function() {
if (awaitingHeartbeat) { ws.close(); return; }
awaitingHeartbeat = true;
heartbeatRef++;
send({ topic: 'phoenix', event: 'heartbeat', payload: {}, ref: String(heartbeatRef) });
}, 30000);
}
function stopHeartbeat() {
if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null; }
awaitingHeartbeat = false;
}
function scheduleReconnect() {
if (reconnectAttempts >= 10) return;
var delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
reconnectAttempts++;
state.reconnecting = true;
reconnectTimer = setTimeout(function() { connect(); }, delay);
}
function send(msg) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
}
}
function fetchState() {
apiCall('GET', '/api/ludo?action=status&match_id=' + state.matchId, null, function(data) {
if (data.ok) {
onMatchUpdate(data.match);
}
});
}
function onMatchUpdate(match) {
state.match = match;
var players = typeof match.players === 'string' ? JSON.parse(match.players) : match.players;
var positions = typeof match.positions === 'string' ? JSON.parse(match.positions) : match.positions;
var winners = typeof match.winners === 'string' ? JSON.parse(match.winners) : match.winners;
var gameState = typeof match.game_state === 'string' ? JSON.parse(match.game_state) : match.game_state;
var chat = typeof match.chat === 'string' ? JSON.parse(match.chat) : match.chat;
// Determine my color
for (var i = 0; i < players.length; i++) {
if (players[i].id === state.userId) {
state.myColor = players[i].color;
break;
}
}
var activePlayers = players.map(function(p) { return p.color; });
var playerNames = {};
players.forEach(function(p) { playerNames[p.color] = p.name; });
// Render board if not yet rendered
if (!document.querySelector('.ludo-board')) {
UI.renderBoard('#ludo-board');
UI.renderDice('#ludo-dice-container');
UI.setTurnElement(document.getElementById('ludo-turn'));
UI.setLogElement(document.getElementById('ludo-log'));
UI.renderPlayerCards(
document.getElementById('ludo-top-players'),
document.getElementById('ludo-bottom-players'),
activePlayers,
playerNames
);
UI.renderPieces(activePlayers);
Chat.init('#ludo-chat', state.matchId, function(text) {
apiCall('POST', '/api/ludo', { action: 'chat', match_id: state.matchId, text: text });
});
}
// Update all piece positions
activePlayers.forEach(function(p) {
if (!positions[p]) return;
for (var i = 0; i < 4; i++) {
UI.setPiecePosition(p, i, positions[p][i]);
}
});
UI.updateStacking(positions, activePlayers);
// Update turn
var currentPlayer = players[match.current_turn];
if (currentPlayer) {
UI.setTurn(currentPlayer.color, currentPlayer.name);
}
// Update dice
if (match.dice_value) {
UI.setDiceValue(match.dice_value);
}
// Update winners
winners.forEach(function(w) { UI.setPlayerFinished(w); });
// Update chat
if (chat && chat.length > 0) {
Chat.loadMessages(chat);
}
// Handle game state
if (match.status === 'completed') {
var winnerColor = winners[0] || '';
var winnerName = playerNames[winnerColor] || winnerColor;
var isWinner = winnerColor === state.myColor;
UI.showResult(
isWinner ? 'فزت!' : winnerName + ' فاز!',
'ترتيب: ' + winners.map(function(w, i) { return (i+1) + '. ' + (playerNames[w] || w); }).join(' | ')
);
UI.disableDice();
return;
}
// Enable/disable dice based on turn
if (currentPlayer && currentPlayer.id === state.userId && gameState.phase === 'dice_not_rolled') {
UI.enableDice();
UI.unhighlightPieces();
} else if (currentPlayer && currentPlayer.id === state.userId && gameState.phase === 'dice_rolled') {
UI.disableDice();
highlightEligible(currentPlayer.color, match.dice_value, positions, activePlayers);
} else {
UI.disableDice();
UI.unhighlightPieces();
}
}
function highlightEligible(playerColor, diceValue, positions, activePlayers) {
var eligible = LudoBot.getEligiblePieces(playerColor, diceValue, positions, activePlayers);
if (eligible.length === 0) {
// No moves, auto-skip
skipTurn();
return;
}
UI.highlightPieces(playerColor, eligible);
}
function onDiceClick() {
if (!state.match) return;
var players = typeof state.match.players === 'string' ? JSON.parse(state.match.players) : state.match.players;
var currentPlayer = players[state.match.current_turn];
if (!currentPlayer || currentPlayer.id !== state.userId) return;
var gameState = typeof state.match.game_state === 'string' ? JSON.parse(state.match.game_state) : state.match.game_state;
if (gameState.phase !== 'dice_not_rolled') return;
UI.disableDice();
apiCall('POST', '/api/ludo', { action: 'roll', match_id: state.matchId }, function(data) {
if (data.ok && data.dice_value) {
UI.animateDiceRoll(data.dice_value, function() {});
}
});
}
function onPieceClick(e) {
var target = e.target.closest('.ludo-piece--highlight');
if (!target) return;
var player = target.dataset.player;
var pieceIdx = parseInt(target.dataset.piece, 10);
if (player !== state.myColor) return;
UI.unhighlightPieces();
UI.disableDice();
apiCall('POST', '/api/ludo', {
action: 'move',
match_id: state.matchId,
piece: pieceIdx
});
}
function skipTurn() {
apiCall('POST', '/api/ludo', {
action: 'move',
match_id: state.matchId,
piece: -1
});
}
function leave() {
apiCall('POST', '/api/ludo', { action: 'leave', match_id: state.matchId });
if (ws) { ws.onclose = null; ws.close(); }
stopHeartbeat();
}
function bindUI() {
UI.listenDiceClick(onDiceClick);
UI.listenPieceClick(onPieceClick);
}
function apiCall(method, url, data, callback) {
var xhr = new XMLHttpRequest();
xhr.open(method, url, true);
xhr.setRequestHeader('Content-Type', 'application/json');
if (state.token) {
xhr.setRequestHeader('Authorization', 'Bearer ' + state.token);
}
xhr.onload = function() {
if (callback) {
try { callback(JSON.parse(xhr.responseText)); }
catch(e) { callback({ error: 'parse_error' }); }
}
};
xhr.onerror = function() {
if (callback) callback({ error: 'network_error' });
};
xhr.send(data ? JSON.stringify(data) : null);
}
function createRoom(playerCount, bots, callback) {
apiCall('POST', '/api/ludo', {
action: 'create',
player_count: playerCount,
bots: bots || []
}, callback);
}
function joinRoom(roomCode, callback) {
apiCall('POST', '/api/ludo', {
action: 'join',
room_code: roomCode
}, callback);
}
function startGame(callback) {
apiCall('POST', '/api/ludo', {
action: 'start',
match_id: state.matchId
}, callback);
}
return {
init: init,
bindUI: bindUI,
fetchState: fetchState,
leave: leave,
createRoom: createRoom,
joinRoom: joinRoom,
startGame: startGame,
getState: function() { return state; }
};
})();
var LudoUI = (function() {
'use strict';
var C = LudoConstants;
var boardEl = null;
var piecesEl = null;
var diceEl = null;
var rollBtn = null;
var turnEl = null;
var logEl = null;
var pieceElements = {};
var playerCards = {};
// Which cells belong to the main path (0-51)
// Map position number to grid [col, row] from COORDINATES_MAP
function getPathCells() {
var cells = [];
for (var i = 0; i <= 51; i++) {
cells.push({ pos: i, col: C.COORDINATES_MAP[i][0], row: C.COORDINATES_MAP[i][1] });
}
return cells;
}
function getHomeEntranceCells() {
var cells = [];
var players = ['P1', 'P2', 'P3', 'P4'];
players.forEach(function(p) {
C.HOME_ENTRANCE[p].forEach(function(pos) {
cells.push({ pos: pos, col: C.COORDINATES_MAP[pos][0], row: C.COORDINATES_MAP[pos][1], player: p });
});
// Home final cell
var homePos = C.HOME_POSITIONS[p];
cells.push({ pos: homePos, col: C.COORDINATES_MAP[homePos][0], row: C.COORDINATES_MAP[homePos][1], player: p });
});
return cells;
}
function renderBoard(containerSelector) {
var container = document.querySelector(containerSelector);
if (!container) return;
container.innerHTML = '';
container.classList.add('ludo-board');
// Render corner bases
['P1', 'P2', 'P3', 'P4'].forEach(function(p) {
var base = document.createElement('div');
base.className = 'ludo-base ludo-base--' + p.toLowerCase();
base.dataset.player = p;
var inner = document.createElement('div');
inner.className = 'ludo-base-inner';
for (var i = 0; i < 4; i++) {
var slot = document.createElement('div');
slot.className = 'ludo-base-slot';
inner.appendChild(slot);
}
base.appendChild(inner);
container.appendChild(base);
});
// Render path cells
var pathCells = getPathCells();
pathCells.forEach(function(cell) {
var el = document.createElement('div');
el.className = 'ludo-cell';
el.dataset.pos = cell.pos;
el.style.gridColumn = (cell.col + 1).toString();
el.style.gridRow = (cell.row + 1).toString();
// Mark safe positions
if (C.SAFE_POSITIONS.indexOf(cell.pos) !== -1) {
el.classList.add('ludo-cell--safe');
}
// Mark start positions with player color
if (cell.pos === C.START_POSITIONS.P1) el.classList.add('ludo-cell--start-p1');
else if (cell.pos === C.START_POSITIONS.P2) el.classList.add('ludo-cell--start-p2');
else if (cell.pos === C.START_POSITIONS.P3) el.classList.add('ludo-cell--start-p3');
else if (cell.pos === C.START_POSITIONS.P4) el.classList.add('ludo-cell--start-p4');
container.appendChild(el);
});
// Render home entrance cells
var homeCells = getHomeEntranceCells();
homeCells.forEach(function(cell) {
var el = document.createElement('div');
el.className = 'ludo-cell ludo-cell--home-' + cell.player.toLowerCase();
el.dataset.pos = cell.pos;
el.style.gridColumn = (cell.col + 1).toString();
el.style.gridRow = (cell.row + 1).toString();
container.appendChild(el);
});
// Center home area
var center = document.createElement('div');
center.className = 'ludo-center';
['p1', 'p2', 'p3', 'p4'].forEach(function(p) {
var tri = document.createElement('div');
tri.className = 'ludo-center-triangle ludo-center-triangle--' + p;
center.appendChild(tri);
});
container.appendChild(center);
// Pieces container (absolute overlay)
piecesEl = document.createElement('div');
piecesEl.className = 'ludo-pieces';
container.parentElement.appendChild(piecesEl);
boardEl = container;
}
function renderPieces(activePlayers) {
if (!piecesEl) return;
piecesEl.innerHTML = '';
pieceElements = {};
activePlayers.forEach(function(player) {
pieceElements[player] = [];
for (var i = 0; i < 4; i++) {
var piece = document.createElement('div');
piece.className = 'ludo-piece ludo-piece--' + player.toLowerCase();
piece.dataset.player = player;
piece.dataset.piece = i;
piecesEl.appendChild(piece);
pieceElements[player].push(piece);
}
});
}
function setPiecePosition(player, piece, position) {
if (!pieceElements[player] || !pieceElements[player][piece]) return;
var coords = C.COORDINATES_MAP[position];
if (!coords) return;
var el = pieceElements[player][piece];
el.style.top = (coords[1] * C.STEP_LENGTH) + '%';
el.style.left = (coords[0] * C.STEP_LENGTH) + '%';
// Mark as home if reached final
if (position === C.HOME_POSITIONS[player]) {
el.classList.add('ludo-piece--home');
} else {
el.classList.remove('ludo-piece--home');
}
}
function highlightPieces(player, pieces) {
pieces.forEach(function(pieceIdx) {
if (pieceElements[player] && pieceElements[player][pieceIdx]) {
pieceElements[player][pieceIdx].classList.add('ludo-piece--highlight');
}
});
}
function unhighlightPieces() {
var all = piecesEl ? piecesEl.querySelectorAll('.ludo-piece--highlight') : [];
all.forEach(function(el) { el.classList.remove('ludo-piece--highlight'); });
}
function updateStacking(positions, activePlayers) {
// Reset stacking
activePlayers.forEach(function(p) {
for (var i = 0; i < 4; i++) {
if (pieceElements[p] && pieceElements[p][i]) {
pieceElements[p][i].removeAttribute('data-stack');
}
}
});
// Find pieces on same position
var posMap = {};
activePlayers.forEach(function(p) {
positions[p].forEach(function(pos, idx) {
var key = String(pos);
if (!posMap[key]) posMap[key] = [];
posMap[key].push({ player: p, piece: idx });
});
});
Object.keys(posMap).forEach(function(key) {
if (posMap[key].length > 1) {
posMap[key].forEach(function(item, stackIdx) {
if (stackIdx > 0 && pieceElements[item.player] && pieceElements[item.player][item.piece]) {
pieceElements[item.player][item.piece].dataset.stack = stackIdx;
}
});
}
});
}
function renderDice(containerSelector) {
var container = containerSelector ? document.querySelector(containerSelector) : null;
if (!container) return;
var area = document.createElement('div');
area.className = 'ludo-dice-area';
diceEl = document.createElement('div');
diceEl.className = 'ludo-dice';
diceEl.id = 'ludo-dice';
for (var i = 0; i < 9; i++) {
var dot = document.createElement('div');
dot.className = 'ludo-dice-dot';
dot.dataset.dot = i;
diceEl.appendChild(dot);
}
rollBtn = document.createElement('button');
rollBtn.className = 'ludo-roll-btn';
rollBtn.id = 'ludo-roll-btn';
rollBtn.textContent = 'ارمِ النرد';
area.appendChild(diceEl);
area.appendChild(rollBtn);
container.appendChild(area);
}
function setDiceValue(value) {
if (!diceEl) return;
var dots = diceEl.querySelectorAll('.ludo-dice-dot');
dots.forEach(function(d) { d.classList.remove('ludo-dice-dot--visible'); });
if (value >= 1 && value <= 6) {
var pattern = C.DICE_FACES[value];
pattern.forEach(function(idx) {
dots[idx].classList.add('ludo-dice-dot--visible');
});
}
}
function animateDiceRoll(finalValue, callback) {
if (!diceEl) { if (callback) callback(); return; }
diceEl.classList.add('ludo-dice--rolling');
var count = 0;
var interval = setInterval(function() {
setDiceValue(1 + Math.floor(Math.random() * 6));
count++;
if (count >= 8) {
clearInterval(interval);
diceEl.classList.remove('ludo-dice--rolling');
setDiceValue(finalValue);
if (callback) callback();
}
}, 80);
}
function enableDice() {
if (rollBtn) rollBtn.removeAttribute('disabled');
}
function disableDice() {
if (rollBtn) rollBtn.setAttribute('disabled', '');
}
function listenDiceClick(callback) {
if (rollBtn) rollBtn.addEventListener('click', callback);
}
function listenPieceClick(callback) {
if (piecesEl) piecesEl.addEventListener('click', callback);
}
function setTurn(player, label) {
// Update turn indicator
if (turnEl) {
turnEl.innerHTML = '<span style="color:var(--ludo-' + player.toLowerCase() + ')">' + (label || C.PLAYER_LABELS[player]) + '</span>';
}
// Update player cards
Object.keys(playerCards).forEach(function(p) {
if (playerCards[p]) {
playerCards[p].classList.toggle('active', p === player);
}
});
}
function renderPlayerCards(containerTop, containerBottom, activePlayers, playerNames) {
playerCards = {};
// Top row: P2, P3 (or whoever is across)
// Bottom row: P1, P4 (or whoever is player's side)
var topPlayers = activePlayers.filter(function(p) { return p === 'P2' || p === 'P3'; });
var bottomPlayers = activePlayers.filter(function(p) { return p === 'P1' || p === 'P4'; });
function makeCard(player) {
var card = document.createElement('div');
card.className = 'ludo-player-card';
card.dataset.player = player;
var dot = document.createElement('div');
dot.className = 'ludo-player-dot ludo-player-dot--' + player.toLowerCase();
var name = document.createElement('span');
name.className = 'ludo-player-name';
name.textContent = playerNames[player] || C.PLAYER_LABELS[player];
var status = document.createElement('span');
status.className = 'ludo-player-status';
card.appendChild(dot);
card.appendChild(name);
card.appendChild(status);
playerCards[player] = card;
return card;
}
if (containerTop) {
containerTop.innerHTML = '';
topPlayers.forEach(function(p) { containerTop.appendChild(makeCard(p)); });
}
if (containerBottom) {
containerBottom.innerHTML = '';
bottomPlayers.forEach(function(p) { containerBottom.appendChild(makeCard(p)); });
}
}
function setPlayerFinished(player) {
if (playerCards[player]) {
playerCards[player].classList.add('finished');
var status = playerCards[player].querySelector('.ludo-player-status');
if (status) status.textContent = '✔';
}
}
function showResult(title, subtitle) {
if (!boardEl || !boardEl.parentElement) return;
var existing = boardEl.parentElement.querySelector('.ludo-result');
if (existing) existing.remove();
var overlay = document.createElement('div');
overlay.className = 'ludo-result';
overlay.innerHTML = '<div class="ludo-result-title">' + title + '</div>' +
'<div class="ludo-result-subtitle">' + subtitle + '</div>' +
'<div class="ludo-result-actions">' +
'<button class="btn btn-gold" id="ludo-play-again">العب مرة تانية</button>' +
'<button class="btn btn-ghost" onclick="window.location.href=\'/ludo\'">القائمة</button>' +
'</div>';
boardEl.parentElement.appendChild(overlay);
}
function hideResult() {
if (!boardEl || !boardEl.parentElement) return;
var overlay = boardEl.parentElement.querySelector('.ludo-result');
if (overlay) overlay.remove();
}
function addLogEntry(text) {
if (!logEl) return;
var entry = document.createElement('div');
entry.className = 'ludo-log-entry';
entry.textContent = text;
logEl.appendChild(entry);
logEl.scrollTop = logEl.scrollHeight;
}
function clearLog() {
if (logEl) logEl.innerHTML = '';
}
function setTurnElement(el) { turnEl = el; }
function setLogElement(el) { logEl = el; }
return {
renderBoard: renderBoard,
renderPieces: renderPieces,
renderDice: renderDice,
renderPlayerCards: renderPlayerCards,
setPiecePosition: setPiecePosition,
highlightPieces: highlightPieces,
unhighlightPieces: unhighlightPieces,
updateStacking: updateStacking,
setDiceValue: setDiceValue,
animateDiceRoll: animateDiceRoll,
enableDice: enableDice,
disableDice: disableDice,
listenDiceClick: listenDiceClick,
listenPieceClick: listenPieceClick,
setTurn: setTurn,
setPlayerFinished: setPlayerFinished,
showResult: showResult,
hideResult: hideResult,
addLogEntry: addLogEntry,
clearLog: clearLog,
setTurnElement: setTurnElement,
setLogElement: setLogElement
};
})();
......@@ -129,7 +129,7 @@ const Puzzles = {
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>';
dot.innerHTML = '<svg class="icon-sm" style="color:var(--text-1)"><use href="/public/icons/sprite.svg#icon-check"></use></svg>';
} else if (i === this.dailySolved) {
dot.classList.add('current');
}
......@@ -336,8 +336,8 @@ const Puzzles = {
// 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)';
if (fromSq) fromSq.style.background = 'var(--board-highlight-green)';
if (toSq) toSq.style.background = 'var(--board-highlight-green)';
},
async reportAttempt(solved, timeMs) {
......
......@@ -3,9 +3,9 @@
$currentRoute = $_GET['route'] ?? '';
$bottomItems = [
['/', 'icon-home', 'الرئيسية'],
['/play', 'icon-play', 'العب'],
['/play', 'icon-play', 'شطرنج'],
['/ludo', 'icon-ludo', 'لودو'],
['/tournaments', 'icon-trophy', 'بطولات'],
['/friends', 'icon-friends', 'اجتماعي'],
['/profile', 'icon-profile', 'حسابي'],
];
foreach ($bottomItems as $item):
......
......@@ -4,7 +4,8 @@
$currentRoute = $_GET['route'] ?? '';
$navItems = [
['/', 'icon-home', 'الرئيسية'],
['/play', 'icon-play', 'العب'],
['/play', 'icon-play', 'شطرنج'],
['/ludo', 'icon-ludo', 'لودو'],
['/puzzles', 'icon-puzzle', 'تمارين'],
['/tournaments', 'icon-trophy', 'بطولات'],
['/leaderboard', 'icon-leaderboard', 'متصدرون'],
......
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