Commit a3c3fae0 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: human vs human multiplayer with Supabase Realtime

- Matchmaking API: queue players by rating/time control, auto-pair
- Multiplayer game API: move validation, resign, draw offer/accept/decline, timeout, abort
- Supabase Realtime WebSocket client: subscribe to match row changes for live game sync
- Matchmaking page: animated search with wait timer, rating range expansion
- Live game page: full board with clocks, move list, resign/draw/abort controls
- Play page: prominent "ضد لاعب حقيقي" multiplayer button with time control selector
- DB: REPLICA IDENTITY FULL on matches table, realtime tenant fixed
- Routes: /matchmaking, /game-live?id=UUID
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent ddaed9a6
<?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;
}
/**
* Helper: query Supabase REST API with the service role key (bypasses RLS).
*/
function supabase_service(string $method, string $endpoint, array $data = []): array {
return supabase_rest($method, $endpoint, $data, SUPABASE_SERVICE_KEY);
}
/**
* Get the appropriate ELO column name for a given time_control enum.
*/
function elo_column_for_time_control(string $timeControl): string {
if (str_starts_with($timeControl, 'bullet')) {
return 'elo_bullet';
}
if (str_starts_with($timeControl, 'blitz')) {
return 'elo_blitz';
}
return 'elo_rapid';
}
// Authenticate the user
$userRes = supabase_auth('user', [], $token, 'GET');
$userId = $userRes['data']['id'] ?? null;
if (!$userId) {
http_response_code(401);
echo json_encode(['error' => 'invalid token']);
exit;
}
$method = $_SERVER['REQUEST_METHOD'];
if ($method !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'method not allowed']);
exit;
}
$input = json_decode(file_get_contents('php://input'), true);
$action = $input['action'] ?? '';
switch ($action) {
// ─── JOIN QUEUE ─────────────────────────────────────────────────────────────
case 'join':
$timeControl = $input['time_control'] ?? '';
$isRated = (bool)($input['is_rated'] ?? true);
if (!$timeControl) {
http_response_code(400);
echo json_encode(['error' => 'time_control is required']);
exit;
}
// Check if player already has a waiting entry
$existing = supabase_service('GET', "matchmaking_queue?player_id=eq.{$userId}&status=eq.waiting&select=id,status");
if (!empty($existing['data'])) {
http_response_code(409);
echo json_encode(['error' => 'already_in_queue', 'queue_id' => $existing['data'][0]['id']]);
exit;
}
// Get player's rating for this time control
$eloCol = elo_column_for_time_control($timeControl);
$profileRes = supabase_service('GET', "players?id=eq.{$userId}&select={$eloCol}");
$playerRating = $profileRes['data'][0][$eloCol] ?? 1200;
$ratingRangeMin = $playerRating - 200;
$ratingRangeMax = $playerRating + 200;
// Look for a compatible waiting opponent
$opponents = supabase_service('GET',
"matchmaking_queue?status=eq.waiting"
. "&time_control=eq.{$timeControl}"
. "&player_id=neq.{$userId}"
. "&rating=gte.{$ratingRangeMin}"
. "&rating=lte.{$ratingRangeMax}"
. "&select=id,player_id,rating"
. "&order=queued_at.asc"
. "&limit=1"
);
if (!empty($opponents['data'])) {
// Found an opponent - create a match
$opponent = $opponents['data'][0];
// Randomly assign white/black
$whitePlayerId = random_int(0, 1) === 0 ? $userId : $opponent['player_id'];
$blackPlayerId = $whitePlayerId === $userId ? $opponent['player_id'] : $userId;
// Determine initial time from time_control enum
$timeMs = match($timeControl) {
'bullet_1_0' => 60000,
'bullet_1_1' => 60000,
'bullet_2_1' => 120000,
'blitz_3_0' => 180000,
'blitz_3_2' => 180000,
'blitz_5_0' => 300000,
'blitz_5_3' => 300000,
'rapid_10_0' => 600000,
'rapid_10_5' => 600000,
'rapid_15_10' => 900000,
'rapid_30_0' => 1800000,
default => 600000,
};
$incrementMs = match($timeControl) {
'bullet_1_1' => 1000,
'bullet_2_1' => 1000,
'blitz_3_2' => 2000,
'blitz_5_3' => 3000,
'rapid_10_5' => 5000,
'rapid_15_10' => 10000,
default => 0,
};
// Create the match
$matchData = [
'game_key' => 'chess',
'white_player_id' => $whitePlayerId,
'black_player_id' => $blackPlayerId,
'match_type' => 'human',
'status' => 'ready',
'time_control' => $timeControl,
'initial_time_ms' => $timeMs,
'increment_ms' => $incrementMs,
'white_time_remaining_ms' => $timeMs,
'black_time_remaining_ms' => $timeMs,
'starting_fen' => 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
'current_fen' => 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
'moves' => json_encode([]),
'move_count' => 0,
'is_rated' => $isRated,
];
$matchRes = supabase_service('POST', 'matches', $matchData);
if ($matchRes['status'] < 200 || $matchRes['status'] >= 300 || empty($matchRes['data'])) {
http_response_code(500);
echo json_encode(['error' => 'failed to create match']);
exit;
}
$matchId = $matchRes['data'][0]['id'];
// Update the opponent's queue entry
supabase_service('PATCH', "matchmaking_queue?id=eq.{$opponent['id']}", [
'status' => 'matched',
'matched_with' => $userId,
'match_id' => $matchId,
]);
// Insert our own queue entry as already matched
$queueEntry = [
'player_id' => $userId,
'game_key' => 'chess',
'time_control' => $timeControl,
'rating' => $playerRating,
'rating_range_min' => $ratingRangeMin,
'rating_range_max' => $ratingRangeMax,
'is_rated' => $isRated,
'status' => 'matched',
'matched_with' => $opponent['player_id'],
'match_id' => $matchId,
];
supabase_service('POST', 'matchmaking_queue', $queueEntry);
echo json_encode([
'status' => 'matched',
'match_id' => $matchId,
'opponent_id' => $opponent['player_id'],
'color' => $whitePlayerId === $userId ? 'white' : 'black',
]);
} else {
// No opponent found - insert into queue as waiting
$queueEntry = [
'player_id' => $userId,
'game_key' => 'chess',
'time_control' => $timeControl,
'rating' => $playerRating,
'rating_range_min' => $ratingRangeMin,
'rating_range_max' => $ratingRangeMax,
'is_rated' => $isRated,
'status' => 'waiting',
];
$insertRes = supabase_service('POST', 'matchmaking_queue', $queueEntry);
if ($insertRes['status'] >= 200 && $insertRes['status'] < 300 && !empty($insertRes['data'])) {
echo json_encode([
'status' => 'waiting',
'queue_id' => $insertRes['data'][0]['id'],
'rating' => $playerRating,
]);
} else {
http_response_code(500);
echo json_encode(['error' => 'failed to join queue']);
}
}
break;
// ─── LEAVE QUEUE ────────────────────────────────────────────────────────────
case 'leave':
$res = supabase_service('PATCH', "matchmaking_queue?player_id=eq.{$userId}&status=eq.waiting", [
'status' => 'cancelled',
]);
if ($res['status'] >= 200 && $res['status'] < 300) {
echo json_encode(['status' => 'cancelled']);
} else {
http_response_code(500);
echo json_encode(['error' => 'failed to leave queue']);
}
break;
// ─── CHECK STATUS ───────────────────────────────────────────────────────────
case 'status':
$res = supabase_service('GET',
"matchmaking_queue?player_id=eq.{$userId}"
. "&status=in.(waiting,matched)"
. "&select=id,status,time_control,rating,matched_with,match_id,queued_at"
. "&order=queued_at.desc"
. "&limit=1"
);
if (!empty($res['data'])) {
$entry = $res['data'][0];
$response = [
'status' => $entry['status'],
'queue_id' => $entry['id'],
'time_control' => $entry['time_control'],
'rating' => $entry['rating'],
'queued_at' => $entry['queued_at'],
];
if ($entry['status'] === 'matched') {
$response['match_id'] = $entry['match_id'];
$response['opponent_id'] = $entry['matched_with'];
}
echo json_encode($response);
} else {
echo json_encode(['status' => 'idle']);
}
break;
default:
http_response_code(400);
echo json_encode(['error' => 'invalid action']);
}
<?php
require_once __DIR__ . '/../config/database.php';
header('Content-Type: application/json');
$token = get_auth_token();
if (!$token) {
http_response_code(401);
echo json_encode(['error' => 'unauthorized']);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'method_not_allowed']);
exit;
}
// Authenticate user
$userRes = supabase_auth('user', [], $token, 'GET');
$userId = $userRes['data']['id'] ?? null;
if (!$userId) {
http_response_code(401);
echo json_encode(['error' => 'invalid_token']);
exit;
}
$input = json_decode(file_get_contents('php://input'), true);
$action = $input['action'] ?? '';
$matchId = $input['match_id'] ?? '';
if (!$matchId) {
http_response_code(400);
echo json_encode(['error' => 'match_id_required']);
exit;
}
// Fetch the match and validate player membership
$matchRes = supabase_rest('GET', "matches?id=eq.{$matchId}&select=*", [], $token);
if (empty($matchRes['data'])) {
http_response_code(404);
echo json_encode(['error' => 'match_not_found']);
exit;
}
$match = $matchRes['data'][0];
$isWhite = ($match['white_player_id'] === $userId);
$isBlack = ($match['black_player_id'] === $userId);
if (!$isWhite && !$isBlack) {
http_response_code(403);
echo json_encode(['error' => 'not_in_match']);
exit;
}
$gameState = is_string($match['game_state']) ? json_decode($match['game_state'], true) : ($match['game_state'] ?? []);
$moves = is_string($match['moves']) ? json_decode($match['moves'], true) : ($match['moves'] ?? []);
switch ($action) {
case 'ready':
if ($match['status'] !== 'ready') {
http_response_code(400);
echo json_encode(['error' => 'match_not_in_ready_state']);
break;
}
if ($isWhite) {
$gameState['white_ready'] = true;
} else {
$gameState['black_ready'] = true;
}
$update = ['game_state' => json_encode($gameState)];
// If both players are now ready, start the match
if (!empty($gameState['white_ready']) && !empty($gameState['black_ready'])) {
$update['status'] = 'in_progress';
$update['started_at'] = date('c');
}
$res = supabase_rest('PATCH', "matches?id=eq.{$matchId}", $update, $token);
if ($res['status'] >= 200 && $res['status'] < 300) {
$started = isset($update['status']) && $update['status'] === 'in_progress';
echo json_encode(['ok' => true, 'started' => $started]);
} else {
http_response_code(500);
echo json_encode(['error' => 'update_failed']);
}
break;
case 'move':
if ($match['status'] !== 'in_progress') {
http_response_code(400);
echo json_encode(['error' => 'match_not_in_progress']);
break;
}
$moveSan = $input['move'] ?? '';
$fen = $input['fen'] ?? '';
$clockRemainingMs = $input['clock_remaining_ms'] ?? null;
if (!$moveSan || !$fen) {
http_response_code(400);
echo json_encode(['error' => 'move_and_fen_required']);
break;
}
// Determine whose turn it is from current FEN
$fenParts = explode(' ', $match['current_fen']);
$turnColor = $fenParts[1] ?? 'w'; // 'w' or 'b'
if ($turnColor === 'w' && !$isWhite) {
http_response_code(400);
echo json_encode(['error' => 'not_your_turn']);
break;
}
if ($turnColor === 'b' && !$isBlack) {
http_response_code(400);
echo json_encode(['error' => 'not_your_turn']);
break;
}
// Append the move
$moves[] = [
'san' => $moveSan,
'fen' => $fen,
'at' => date('c'),
];
$gameState['last_move_at'] = date('c');
$update = [
'current_fen' => $fen,
'moves' => json_encode($moves),
'move_count' => count($moves),
'game_state' => json_encode($gameState),
];
if ($isWhite && $clockRemainingMs !== null) {
$update['white_time_remaining_ms'] = (int) $clockRemainingMs;
} elseif ($isBlack && $clockRemainingMs !== null) {
$update['black_time_remaining_ms'] = (int) $clockRemainingMs;
}
$res = supabase_rest('PATCH', "matches?id=eq.{$matchId}", $update, $token);
if ($res['status'] >= 200 && $res['status'] < 300) {
echo json_encode(['ok' => true, 'move_count' => count($moves)]);
} else {
http_response_code(500);
echo json_encode(['error' => 'update_failed']);
}
break;
case 'resign':
if ($match['status'] !== 'in_progress') {
http_response_code(400);
echo json_encode(['error' => 'match_not_in_progress']);
break;
}
$result = $isWhite ? 'black_wins' : 'white_wins';
$update = [
'status' => 'completed',
'result' => $isWhite ? 'white_resign' : 'black_resign',
'completed_at' => date('c'),
];
$res = supabase_rest('PATCH', "matches?id=eq.{$matchId}", $update, $token);
if ($res['status'] >= 200 && $res['status'] < 300) {
echo json_encode(['ok' => true, 'result' => $update['result']]);
} else {
http_response_code(500);
echo json_encode(['error' => 'update_failed']);
}
break;
case 'draw_offer':
if ($match['status'] !== 'in_progress') {
http_response_code(400);
echo json_encode(['error' => 'match_not_in_progress']);
break;
}
$gameState['draw_offered_by'] = $userId;
$update = ['game_state' => json_encode($gameState)];
$res = supabase_rest('PATCH', "matches?id=eq.{$matchId}", $update, $token);
if ($res['status'] >= 200 && $res['status'] < 300) {
echo json_encode(['ok' => true, 'draw_offered_by' => $userId]);
} else {
http_response_code(500);
echo json_encode(['error' => 'update_failed']);
}
break;
case 'draw_accept':
if ($match['status'] !== 'in_progress') {
http_response_code(400);
echo json_encode(['error' => 'match_not_in_progress']);
break;
}
$offeredBy = $gameState['draw_offered_by'] ?? null;
if (!$offeredBy) {
http_response_code(400);
echo json_encode(['error' => 'no_draw_offer_pending']);
break;
}
// The accepting player must be the one who did NOT offer
if ($offeredBy === $userId) {
http_response_code(400);
echo json_encode(['error' => 'cannot_accept_own_draw_offer']);
break;
}
unset($gameState['draw_offered_by']);
$update = [
'status' => 'completed',
'result' => 'mutual_draw',
'completed_at' => date('c'),
'game_state' => json_encode($gameState),
];
$res = supabase_rest('PATCH', "matches?id=eq.{$matchId}", $update, $token);
if ($res['status'] >= 200 && $res['status'] < 300) {
echo json_encode(['ok' => true, 'result' => 'mutual_draw']);
} else {
http_response_code(500);
echo json_encode(['error' => 'update_failed']);
}
break;
case 'draw_decline':
if ($match['status'] !== 'in_progress') {
http_response_code(400);
echo json_encode(['error' => 'match_not_in_progress']);
break;
}
$offeredBy = $gameState['draw_offered_by'] ?? null;
if (!$offeredBy) {
http_response_code(400);
echo json_encode(['error' => 'no_draw_offer_pending']);
break;
}
// The declining player must be the one who did NOT offer
if ($offeredBy === $userId) {
http_response_code(400);
echo json_encode(['error' => 'cannot_decline_own_draw_offer']);
break;
}
unset($gameState['draw_offered_by']);
$update = ['game_state' => json_encode($gameState)];
$res = supabase_rest('PATCH', "matches?id=eq.{$matchId}", $update, $token);
if ($res['status'] >= 200 && $res['status'] < 300) {
echo json_encode(['ok' => true, 'draw_declined' => true]);
} else {
http_response_code(500);
echo json_encode(['error' => 'update_failed']);
}
break;
case 'timeout':
if ($match['status'] !== 'in_progress') {
http_response_code(400);
echo json_encode(['error' => 'match_not_in_progress']);
break;
}
// The player is flagging their opponent's timeout
if ($isWhite) {
$opponentTimeMs = (int) ($match['black_time_remaining_ms'] ?? 0);
$result = 'black_timeout';
} else {
$opponentTimeMs = (int) ($match['white_time_remaining_ms'] ?? 0);
$result = 'white_timeout';
}
if ($opponentTimeMs > 0) {
http_response_code(400);
echo json_encode(['error' => 'opponent_has_time_remaining', 'remaining_ms' => $opponentTimeMs]);
break;
}
$update = [
'status' => 'completed',
'result' => $result,
'completed_at' => date('c'),
];
$res = supabase_rest('PATCH', "matches?id=eq.{$matchId}", $update, $token);
if ($res['status'] >= 200 && $res['status'] < 300) {
echo json_encode(['ok' => true, 'result' => $result]);
} else {
http_response_code(500);
echo json_encode(['error' => 'update_failed']);
}
break;
case 'abort':
if ($match['status'] !== 'in_progress' && $match['status'] !== 'ready') {
http_response_code(400);
echo json_encode(['error' => 'match_cannot_be_aborted']);
break;
}
$moveCount = (int) ($match['move_count'] ?? 0);
if ($moveCount >= 2) {
http_response_code(400);
echo json_encode(['error' => 'too_many_moves_to_abort', 'move_count' => $moveCount]);
break;
}
$update = [
'status' => 'aborted',
'result' => 'aborted',
'completed_at' => date('c'),
];
$res = supabase_rest('PATCH', "matches?id=eq.{$matchId}", $update, $token);
if ($res['status'] >= 200 && $res['status'] < 300) {
echo json_encode(['ok' => true, 'result' => 'aborted']);
} else {
http_response_code(500);
echo json_encode(['error' => 'update_failed']);
}
break;
default:
http_response_code(400);
echo json_encode(['error' => 'invalid_action', 'valid_actions' => [
'ready', 'move', 'resign', 'draw_offer', 'draw_accept', 'draw_decline', 'timeout', 'abort'
]]);
}
......@@ -13,6 +13,10 @@ if ($route === '' || $route === 'home') {
require 'pages/play.php';
} elseif ($route === 'game') {
require 'pages/game.php';
} elseif ($route === 'game-live') {
require 'pages/game-live.php';
} elseif ($route === 'matchmaking') {
require 'pages/matchmaking.php';
} elseif ($route === 'bots') {
require 'pages/bots.php';
} elseif ($route === 'profile') {
......
<?php
$pageTitle = 'EL3AB - مباراة مباشرة';
$extraCss = '/public/css/chessboard.css';
?>
<?php require __DIR__ . '/../includes/header.php'; ?>
<div class="game-layout" id="game-container">
<!-- Board Column -->
<div class="game-board-column">
<!-- Opponent info + clock -->
<div class="game-header">
<div class="game-player">
<div class="avatar avatar-sm" id="opponent-avatar">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-profile"></use></svg>
</div>
<div>
<div class="game-player-name" id="opponent-name">خصم</div>
<div class="game-player-rating" id="opponent-rating">1200</div>
</div>
</div>
<div class="game-clock" id="clock-top">10:00</div>
</div>
<!-- Board + Eval Bar -->
<div class="game-board-section">
<div class="eval-bar" id="eval-bar">
<span class="eval-bar-label eval-bar-label-top" id="eval-label-top"></span>
<div class="eval-bar-fill" id="eval-bar-fill" style="height:50%;"></div>
<span class="eval-bar-label eval-bar-label-bottom" id="eval-label-bottom"></span>
</div>
<div class="board-wrapper">
<div class="board" id="board"></div>
</div>
</div>
<!-- Thinking indicator (opponent's turn) -->
<div class="thinking" id="thinking-indicator" style="display:none;">
<div class="thinking-dots">
<span></span><span></span><span></span>
</div>
<span>ينتظر نقلة الخصم...</span>
</div>
<!-- Player info + clock -->
<div class="game-header">
<div class="game-player">
<div class="avatar avatar-sm">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-profile"></use></svg>
</div>
<div>
<div class="game-player-name" id="player-name">انت</div>
<div class="game-player-rating" id="player-rating">1200</div>
</div>
</div>
<div class="game-clock" id="clock-bottom">10:00</div>
</div>
</div>
<!-- Side Panel (Desktop) -->
<div class="game-side-panel" id="side-panel">
<div class="analysis-panel">
<div class="opening-display" id="opening-display" style="display:none;">
<span class="opening-eco" id="opening-eco"></span>
<span class="opening-name" id="opening-name"></span>
</div>
<div class="move-list-pro" id="move-list"></div>
<div class="panel-status" id="game-status">بانتظار الخصم...</div>
<!-- Draw offer banner -->
<div class="draw-offer-banner" id="draw-offer-banner" style="display:none;">
<span>الخصم يعرض التعادل</span>
<div class="draw-offer-actions">
<button class="btn btn-success btn-sm" onclick="LiveGame.acceptDraw()">قبول</button>
<button class="btn btn-ghost btn-sm" onclick="LiveGame.declineDraw()">رفض</button>
</div>
</div>
<div class="panel-controls" id="game-controls">
<button class="btn btn-ghost btn-sm" onclick="LiveGame.resign()" id="btn-resign">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-flag"></use></svg>
استسلام
</button>
<button class="btn btn-ghost btn-sm" onclick="LiveGame.offerDraw()" id="btn-draw">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-shield"></use></svg>
تعادل
</button>
<button class="btn btn-ghost btn-sm" onclick="Board.flip()">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-eye"></use></svg>
اقلب
</button>
</div>
<div class="postgame-controls" id="postgame-controls" style="display:none;">
<button class="btn btn-ghost btn-sm" onclick="LiveGame.exportPGN()">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-download"></use></svg>
PGN
</button>
<button class="btn btn-ghost btn-sm" onclick="LiveGame.copyFEN()">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-copy"></use></svg>
FEN
</button>
<button class="btn btn-cyan btn-sm" onclick="window.location.href='/play'">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-arrow-right"></use></svg>
رجوع
</button>
</div>
</div>
</div>
<!-- Mobile Panel -->
<div class="game-mobile-panel" id="mobile-panel">
<div class="analysis-panel">
<div class="opening-display" id="opening-display-mobile" style="display:none;">
<span class="opening-eco" id="opening-eco-mobile"></span>
<span class="opening-name" id="opening-name-mobile"></span>
</div>
<div class="move-list-pro" id="move-list-mobile"></div>
<div class="panel-status" id="game-status-mobile">بانتظار الخصم...</div>
<div class="draw-offer-banner" id="draw-offer-banner-mobile" style="display:none;">
<span>الخصم يعرض التعادل</span>
<div class="draw-offer-actions">
<button class="btn btn-success btn-sm" onclick="LiveGame.acceptDraw()">قبول</button>
<button class="btn btn-ghost btn-sm" onclick="LiveGame.declineDraw()">رفض</button>
</div>
</div>
<div class="panel-controls" id="game-controls-mobile">
<button class="btn btn-ghost btn-sm" onclick="LiveGame.resign()">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-flag"></use></svg>
استسلام
</button>
<button class="btn btn-ghost btn-sm" onclick="LiveGame.offerDraw()">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-shield"></use></svg>
تعادل
</button>
<button class="btn btn-ghost btn-sm" onclick="Board.flip()">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-eye"></use></svg>
اقلب
</button>
</div>
</div>
</div>
</div>
<script src="/public/js/chess.min.js"></script>
<script src="/public/js/board.js"></script>
<script src="/public/js/openings.js"></script>
<script src="/public/js/realtime.js"></script>
<script src="/public/js/game-live.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
if (!App.isLoggedIn()) {
window.location.href = '/login';
return;
}
const params = new URLSearchParams(window.location.search);
const matchId = params.get('id');
if (!matchId) {
App.toast('معرف المباراة مفقود', 'error');
setTimeout(() => window.location.href = '/play', 1500);
return;
}
LiveGame.init(matchId);
});
</script>
<?php require __DIR__ . '/../includes/footer.php'; ?>
<?php
$pageTitle = 'EL3AB - البحث عن خصم';
$extraCss = '/public/css/chessboard.css';
?>
<?php require __DIR__ . '/../includes/header.php'; ?>
<div class="matchmaking-page" id="matchmaking-page">
<!-- Searching state -->
<div class="mm-searching" id="mm-searching">
<div class="mm-animation">
<div class="mm-ring"></div>
<div class="mm-ring mm-ring-2"></div>
<svg class="mm-icon"><use href="/public/icons/sprite.svg#icon-sword"></use></svg>
</div>
<h2 class="mm-title">جاري البحث عن خصم...</h2>
<p class="mm-subtitle" id="mm-time-label">بلتز 5 دقائق</p>
<div class="mm-stats">
<div class="mm-stat">
<span class="mm-stat-label">الوقت</span>
<span class="mm-stat-value" id="mm-wait-time">0:00</span>
</div>
<div class="mm-stat">
<span class="mm-stat-label">التصنيف</span>
<span class="mm-stat-value" id="mm-rating">1200</span>
</div>
<div class="mm-stat">
<span class="mm-stat-label">النطاق</span>
<span class="mm-stat-value" id="mm-range">+/- 200</span>
</div>
</div>
<button class="btn btn-ghost btn-block" id="btn-cancel-search" onclick="Matchmaking.cancel()">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-x"></use></svg>
الغاء البحث
</button>
</div>
<!-- Found state -->
<div class="mm-found" id="mm-found" style="display:none;">
<div class="mm-found-animation">
<svg class="mm-found-icon"><use href="/public/icons/sprite.svg#icon-check"></use></svg>
</div>
<h2 class="mm-title">تم ايجاد خصم!</h2>
<p class="mm-subtitle">جاري تحضير المباراة...</p>
</div>
</div>
<style>
.matchmaking-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
padding: 24px;
}
.mm-searching {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
width: 100%;
max-width: 360px;
}
.mm-animation {
position: relative;
width: 120px;
height: 120px;
display: flex;
align-items: center;
justify-content: center;
}
.mm-ring {
position: absolute;
inset: 0;
border: 3px solid transparent;
border-top-color: var(--cyan);
border-radius: 50%;
animation: mm-spin 1.5s linear infinite;
}
.mm-ring-2 {
inset: 10px;
border-top-color: var(--gold);
animation-direction: reverse;
animation-duration: 2s;
}
@keyframes mm-spin {
to { transform: rotate(360deg); }
}
.mm-icon {
width: 40px;
height: 40px;
color: var(--gold);
}
.mm-title {
font-size: 20px;
font-weight: 700;
}
.mm-subtitle {
font-size: 14px;
color: var(--text-2);
margin-top: -12px;
}
.mm-stats {
display: flex;
gap: 16px;
width: 100%;
justify-content: center;
}
.mm-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 12px 16px;
min-width: 80px;
}
.mm-stat-label {
font-size: 11px;
color: var(--text-3);
}
.mm-stat-value {
font-size: 16px;
font-weight: 700;
font-family: var(--font-en);
}
/* Found state */
.mm-found {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.mm-found-animation {
width: 80px;
height: 80px;
background: var(--success);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
animation: mm-pop 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes mm-pop {
from { transform: scale(0); }
to { transform: scale(1); }
}
.mm-found-icon {
width: 40px;
height: 40px;
color: #fff;
}
</style>
<script>
const Matchmaking = {
queueId: null,
pollInterval: null,
waitStart: null,
waitInterval: null,
timeControl: 'blitz_5_0',
initialTime: 300000,
increment: 0,
init() {
const params = new URLSearchParams(window.location.search);
this.timeControl = params.get('tc') || 'blitz_5_0';
this.initialTime = parseInt(params.get('time') || '300000');
this.increment = parseInt(params.get('inc') || '0');
const tcLabels = {
'bullet_1_0': 'بوليت 1 دقيقة',
'bullet_1_1': 'بوليت 1|1',
'bullet_2_1': 'بوليت 2|1',
'blitz_3_0': 'بلتز 3 دقائق',
'blitz_3_2': 'بلتز 3|2',
'blitz_5_0': 'بلتز 5 دقائق',
'blitz_5_3': 'بلتز 5|3',
'rapid_10_0': 'سريع 10 دقائق',
'rapid_10_5': 'سريع 10|5',
'rapid_15_10': 'سريع 15|10',
'rapid_30_0': 'كلاسيكي 30 دقيقة',
};
document.getElementById('mm-time-label').textContent = tcLabels[this.timeControl] || this.timeControl;
this.joinQueue();
},
async joinQueue() {
this.waitStart = Date.now();
this.startWaitTimer();
const res = await App.fetch('/api/matchmaking', {
method: 'POST',
body: JSON.stringify({
action: 'join',
time_control: this.timeControl,
initial_time_ms: this.initialTime,
increment_ms: this.increment,
is_rated: true
})
});
if (res && res.queue_id) {
this.queueId = res.queue_id;
if (res.rating) {
document.getElementById('mm-rating').textContent = res.rating;
}
if (res.matched) {
this.onMatched(res.match_id);
} else {
this.startPolling();
}
} else if (res && res.match_id) {
this.onMatched(res.match_id);
} else {
App.toast('خطأ في الاتصال', 'error');
setTimeout(() => window.location.href = '/play', 2000);
}
},
startPolling() {
this.pollInterval = setInterval(async () => {
const res = await App.fetch('/api/matchmaking', {
method: 'POST',
body: JSON.stringify({
action: 'status',
queue_id: this.queueId
})
});
if (res && res.status === 'matched' && res.match_id) {
this.onMatched(res.match_id);
}
}, 2000);
},
startWaitTimer() {
const el = document.getElementById('mm-wait-time');
this.waitInterval = setInterval(() => {
const elapsed = Math.floor((Date.now() - this.waitStart) / 1000);
const mins = Math.floor(elapsed / 60);
const secs = elapsed % 60;
el.textContent = mins + ':' + secs.toString().padStart(2, '0');
// Expand range display after time
const range = 200 + Math.floor(elapsed / 10) * 50;
document.getElementById('mm-range').textContent = '+/- ' + Math.min(range, 600);
}, 1000);
},
onMatched(matchId) {
this.stopPolling();
document.getElementById('mm-searching').style.display = 'none';
document.getElementById('mm-found').style.display = 'flex';
setTimeout(() => {
window.location.href = '/game-live?id=' + matchId;
}, 1500);
},
async cancel() {
this.stopPolling();
if (this.queueId) {
await App.fetch('/api/matchmaking', {
method: 'POST',
body: JSON.stringify({
action: 'leave',
queue_id: this.queueId
})
});
}
window.location.href = '/play';
},
stopPolling() {
if (this.pollInterval) clearInterval(this.pollInterval);
if (this.waitInterval) clearInterval(this.waitInterval);
this.pollInterval = null;
this.waitInterval = null;
}
};
document.addEventListener('DOMContentLoaded', () => {
if (!App.isLoggedIn()) {
window.location.href = '/login';
return;
}
Matchmaking.init();
});
</script>
<?php require __DIR__ . '/../includes/footer.php'; ?>
......@@ -11,6 +11,33 @@
<!-- Game Mode Selection -->
<div class="space-y-3">
<!-- VS Human - 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:#fff"><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>
<div class="tab-group" id="mp-time-tabs">
<button class="tab" data-tc="bullet_1_0" data-time="60000" data-inc="0">1 د</button>
<button class="tab" data-tc="blitz_3_0" data-time="180000" data-inc="0">3 د</button>
<button class="tab active" data-tc="blitz_5_0" data-time="300000" data-inc="0">5 د</button>
<button class="tab" data-tc="rapid_10_0" data-time="600000" data-inc="0">10 د</button>
</div>
<button class="btn btn-gold btn-block btn-lg" onclick="startMultiplayer()">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-search"></use></svg>
ابحث عن خصم
</button>
</div>
</div>
<!-- VS Bot -->
<a href="/bots" class="card card-hover" style="display:block;text-decoration:none;">
<div class="card-body" style="display:flex;align-items:center;gap:16px;">
......@@ -25,14 +52,14 @@
</div>
</a>
<!-- Quick Match -->
<!-- Quick Match vs Bot -->
<div class="card card-hover" style="cursor:pointer;" onclick="startQuickMatch()">
<div class="card-body" style="display:flex;align-items:center;gap:16px;">
<div class="avatar" style="background:var(--bg-3);">
<svg class="icon-lg" style="color:var(--gold)"><use href="/public/icons/sprite.svg#icon-sword"></use></svg>
<svg class="icon-lg" style="color:var(--gold)"><use href="/public/icons/sprite.svg#icon-bot"></use></svg>
</div>
<div style="flex:1;">
<p style="font-size:16px;font-weight:600;">مباراة سريعة</p>
<p style="font-size:16px;font-weight:600;">مباراة سريعة ضد بوت</p>
<p class="text-muted text-sm">5 دقائق ضد بوت عشوائي</p>
</div>
<svg class="icon" style="color:var(--text-3);transform:scaleX(-1)"><use href="/public/icons/sprite.svg#icon-arrow-right"></use></svg>
......@@ -127,6 +154,14 @@ function getSelectedColor() {
return color;
}
function startMultiplayer() {
const active = document.querySelector('#mp-time-tabs .tab.active');
const tc = active.dataset.tc;
const time = active.dataset.time;
const inc = active.dataset.inc;
window.location.href = '/matchmaking?tc=' + tc + '&time=' + time + '&inc=' + inc;
}
function startQuickMatch() {
const bots = ['amina','tarek','nour','omar','layla','ziad'];
const bot = bots[Math.floor(Math.random() * bots.length)];
......
......@@ -397,6 +397,24 @@
min-width: 0;
}
/* Draw offer banner */
.draw-offer-banner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-top: 1px solid var(--border);
background: rgba(255, 199, 40, 0.1);
direction: rtl;
font-size: 13px;
font-weight: 600;
}
.draw-offer-actions {
display: flex;
gap: 6px;
}
/* Post-game Controls */
.postgame-controls {
display: flex;
......
......@@ -95,7 +95,6 @@ const Board = {
const settings = this.getSettings();
if (settings.coords === false) return;
// Remove old coords
if (this.coordsFile) this.coordsFile.remove();
if (this.coordsRank) this.coordsRank.remove();
......
const LiveGame = {
chess: null,
matchId: null,
playerColor: null,
playerId: null,
opponentId: null,
isGameOver: false,
moveHistory: [],
clockWhite: 0,
clockBlack: 0,
clockInterval: null,
activeClock: null,
lastTickTime: 0,
increment: 0,
currentMoveIndex: -1,
audioCtx: null,
openings: {
'e4': 'King Pawn', 'e4 e5': 'Open Game',
'e4 e5 Nf3 Nc6 Bb5': 'Ruy Lopez', 'e4 e5 Nf3 Nc6 Bc4': 'Italian Game',
'e4 c5': 'Sicilian Defense', 'e4 e6': 'French Defense',
'e4 c6': 'Caro-Kann Defense', 'd4': 'Queen Pawn',
'd4 d5 c4': "Queen's Gambit", 'd4 Nf6 c4 g6': "King's Indian",
'd4 Nf6 c4 e6 Nc3 Bb4': 'Nimzo-Indian', 'c4': 'English Opening',
'Nf3': 'Reti Opening'
},
async init(matchId) {
this.matchId = matchId;
this.playerId = App.user ? App.user.id : null;
const res = await App.fetch('/api/game?action=get&id=' + matchId);
if (!res || !res.game) {
App.toast('المباراة غير موجودة', 'error');
setTimeout(() => window.location.href = '/play', 1500);
return;
}
const match = res.game;
if (match.white_player_id === this.playerId) {
this.playerColor = 'w';
this.opponentId = match.black_player_id;
} else if (match.black_player_id === this.playerId) {
this.playerColor = 'b';
this.opponentId = match.white_player_id;
} else {
App.toast('لست طرفا في هذه المباراة', 'error');
return;
}
this.clockWhite = (match.white_time_remaining_ms || match.initial_time_ms) / 1000;
this.clockBlack = (match.black_time_remaining_ms || match.initial_time_ms) / 1000;
this.increment = (match.increment_ms || 0) / 1000;
this.chess = new Chess(match.current_fen || undefined);
Board.init('board', {
flipped: this.playerColor === 'b',
playerColor: this.playerColor,
onMove: (move) => this.handlePlayerMove(move)
});
Board.setPosition(this.chess);
Board.pushPosition(this.chess.fen());
this.loadExistingMoves(match);
this.updateUI(match);
this.updateClockDisplay();
this.updateMoveList();
Realtime.connect(App.token);
Realtime.subscribe(matchId, (record) => this.onRealtimeUpdate(record));
if (match.status === 'ready') {
this.signalReady();
} else if (match.status === 'in_progress') {
this.startGame();
}
},
loadExistingMoves(match) {
let moves = match.moves;
if (typeof moves === 'string') {
try { moves = JSON.parse(moves); } catch(e) { moves = []; }
}
if (!moves || !Array.isArray(moves)) return;
moves.forEach(m => {
const san = typeof m === 'string' ? m : m.san;
if (san) this.moveHistory.push(san);
});
this.currentMoveIndex = this.moveHistory.length - 1;
},
updateUI(match) {
const playerNameEl = document.getElementById('player-name');
const opponentNameEl = document.getElementById('opponent-name');
const playerRatingEl = document.getElementById('player-rating');
const opponentRatingEl = document.getElementById('opponent-rating');
if (playerNameEl) playerNameEl.textContent = App.user?.user_metadata?.display_name || 'انت';
if (playerRatingEl) {
const r = this.playerColor === 'w' ? match.white_rating_before : match.black_rating_before;
playerRatingEl.textContent = r || '1200';
}
if (opponentRatingEl) {
const r = this.playerColor === 'w' ? match.black_rating_before : match.white_rating_before;
opponentRatingEl.textContent = r || '1200';
}
},
async signalReady() {
this.updateStatus('بانتظار الخصم...');
await App.fetch('/api/multiplayer', {
method: 'POST',
body: JSON.stringify({ action: 'ready', match_id: this.matchId })
});
},
startGame() {
const turn = this.chess.turn();
if (turn === this.playerColor) {
Board.setEnabled(true);
this.updateStatus('دورك');
} else {
Board.setEnabled(false);
this.updateStatus('دور الخصم');
this.showThinking(true);
}
this.startClock(turn);
},
handlePlayerMove(move) {
if (this.isGameOver) return;
if (this.chess.turn() !== this.playerColor) return;
const result = this.chess.move({
from: move.from,
to: move.to,
promotion: move.promotion || 'q'
});
if (!result) return;
this.moveHistory.push(result.san);
this.currentMoveIndex = this.moveHistory.length - 1;
Board.lastMove = { from: move.from, to: move.to };
Board.setPosition(this.chess);
Board.pushPosition(this.chess.fen());
this.applyIncrement(this.playerColor);
this.switchClock();
this.updateMoveList();
this.updateStatus('دور الخصم');
this.showThinking(true);
this.playMoveSound(result);
Board.setEnabled(false);
const myClockMs = Math.round(
(this.playerColor === 'w' ? this.clockWhite : this.clockBlack) * 1000
);
App.fetch('/api/multiplayer', {
method: 'POST',
body: JSON.stringify({
action: 'move',
match_id: this.matchId,
move: result.san,
fen: this.chess.fen(),
clock_remaining_ms: myClockMs
})
});
if (this.chess.game_over()) {
this.handleGameOver();
}
},
onRealtimeUpdate(record) {
if (!record) return;
if (record.status === 'in_progress' && !this.activeClock) {
this.startGame();
return;
}
if (record.status === 'completed' || record.status === 'aborted') {
this.onGameEnded(record);
return;
}
const gameState = typeof record.game_state === 'string'
? JSON.parse(record.game_state || '{}') : (record.game_state || {});
if (gameState.draw_offered_by && gameState.draw_offered_by !== this.playerId) {
this.showDrawOffer(true);
} else {
this.showDrawOffer(false);
}
if (record.current_fen && record.current_fen !== this.chess.fen()) {
this.applyOpponentMove(record);
}
},
applyOpponentMove(record) {
const newFen = record.current_fen;
const tempChess = new Chess(newFen);
let moves = record.moves;
if (typeof moves === 'string') {
try { moves = JSON.parse(moves); } catch(e) { moves = []; }
}
if (moves && moves.length > this.moveHistory.length) {
const lastMove = moves[moves.length - 1];
const san = typeof lastMove === 'string' ? lastMove : lastMove.san;
const result = this.chess.move(san);
if (result) {
this.moveHistory.push(san);
this.currentMoveIndex = this.moveHistory.length - 1;
Board.animateMove(result.from, result.to, () => {
Board.lastMove = { from: result.from, to: result.to };
Board.setPosition(this.chess);
Board.pushPosition(this.chess.fen());
this.updateMoveList();
this.playMoveSound(result);
});
}
}
const opponentColor = this.playerColor === 'w' ? 'b' : 'w';
if (opponentColor === 'w') {
this.clockWhite = (record.white_time_remaining_ms || 0) / 1000;
} else {
this.clockBlack = (record.black_time_remaining_ms || 0) / 1000;
}
if (this.chess.turn() === this.playerColor) {
Board.setEnabled(true);
this.updateStatus('دورك');
this.showThinking(false);
this.switchClock();
}
if (this.chess.game_over()) {
this.handleGameOver();
}
},
onGameEnded(record) {
this.isGameOver = true;
this.stopClock();
Board.setEnabled(false);
Realtime.unsubscribe();
let title, subtitle;
const result = record.result;
if (result === 'white_wins' || result === 'black_wins') {
const winner = result === 'white_wins' ? 'w' : 'b';
title = winner === this.playerColor ? 'فزت!' : 'خسرت';
subtitle = 'كش مات';
} else if (result === 'white_resign' || result === 'black_resign') {
const resigned = result === 'white_resign' ? 'w' : 'b';
title = resigned === this.playerColor ? 'خسرت' : 'فزت!';
subtitle = 'استسلام';
} else if (result === 'white_timeout' || result === 'black_timeout') {
const timedOut = result === 'white_timeout' ? 'w' : 'b';
title = timedOut === this.playerColor ? 'خسرت بالوقت' : 'فزت بالوقت!';
subtitle = 'انتهى الوقت';
} else if (result === 'mutual_draw' || result === 'stalemate' || result === 'draw') {
title = 'تعادل';
subtitle = result === 'mutual_draw' ? 'باتفاق' : 'جمود';
} else if (result === 'aborted') {
title = 'المباراة ملغاة';
subtitle = '';
} else {
title = 'انتهت المباراة';
subtitle = result || '';
}
this.showResult(title, subtitle);
},
handleGameOver() {
if (this.isGameOver) return;
this.isGameOver = true;
this.stopClock();
Board.setEnabled(false);
let title, subtitle;
if (this.chess.in_checkmate()) {
const winner = this.chess.turn() === 'w' ? 'b' : 'w';
title = winner === this.playerColor ? 'فزت!' : 'خسرت';
subtitle = 'كش مات';
} else if (this.chess.in_stalemate()) {
title = 'تعادل';
subtitle = 'جمود';
} else if (this.chess.in_draw()) {
title = 'تعادل';
subtitle = 'قطع غير كافية';
}
this.showResult(title, subtitle);
this.playGameEndSound(title === 'فزت!');
},
showResult(title, subtitle) {
this.updateStatus(title);
document.getElementById('game-controls').style.display = 'none';
document.getElementById('postgame-controls').style.display = 'flex';
const mobileControls = document.getElementById('game-controls-mobile');
if (mobileControls) mobileControls.style.display = 'none';
const wrapper = document.querySelector('.board-wrapper');
if (!wrapper) return;
const overlay = document.createElement('div');
overlay.className = 'game-result';
overlay.innerHTML =
'<div class="game-result-title">' + title + '</div>' +
'<div class="game-result-subtitle">' + subtitle + '</div>' +
'<div class="game-controls" style="margin-top:16px;">' +
'<button class="btn btn-cyan" onclick="window.location.href=\'/play\'">العب مرة اخرى</button>' +
'</div>';
wrapper.appendChild(overlay);
},
// --- Clock ---
startClock(color) {
this.stopClock();
this.activeClock = color;
this.lastTickTime = performance.now();
this.clockInterval = setInterval(() => {
const now = performance.now();
const elapsed = (now - this.lastTickTime) / 1000;
this.lastTickTime = now;
if (this.activeClock === 'w') {
this.clockWhite -= elapsed;
if (this.clockWhite <= 0) {
this.clockWhite = 0;
this.onTimeout('w');
}
} else {
this.clockBlack -= elapsed;
if (this.clockBlack <= 0) {
this.clockBlack = 0;
this.onTimeout('b');
}
}
this.updateClockDisplay();
}, 50);
},
stopClock() {
if (this.clockInterval) {
clearInterval(this.clockInterval);
this.clockInterval = null;
}
},
switchClock() {
const next = this.activeClock === 'w' ? 'b' : 'w';
this.startClock(next);
},
applyIncrement(color) {
if (this.increment <= 0) return;
if (color === 'w') this.clockWhite += this.increment;
else this.clockBlack += this.increment;
},
onTimeout(color) {
this.stopClock();
if (color !== this.playerColor) {
App.fetch('/api/multiplayer', {
method: 'POST',
body: JSON.stringify({ action: 'timeout', match_id: this.matchId })
});
}
},
updateClockDisplay() {
const topClock = document.getElementById('clock-top');
const bottomClock = document.getElementById('clock-bottom');
if (!topClock || !bottomClock) return;
const topColor = this.playerColor === 'w' ? 'b' : 'w';
const bottomColor = this.playerColor;
const topTime = topColor === 'w' ? this.clockWhite : this.clockBlack;
const bottomTime = bottomColor === 'w' ? this.clockWhite : this.clockBlack;
topClock.textContent = this.formatTime(topTime);
bottomClock.textContent = this.formatTime(bottomTime);
topClock.className = 'game-clock' + (this.activeClock === topColor ? ' active' : '') + (topTime <= 30 ? ' low' : '');
bottomClock.className = 'game-clock' + (this.activeClock === bottomColor ? ' active' : '') + (bottomTime <= 30 ? ' low' : '');
},
formatTime(seconds) {
if (seconds <= 0) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
if (seconds < 10) {
const tenths = Math.floor((seconds % 1) * 10);
return secs + '.' + tenths;
}
if (seconds < 60) return '0:' + secs.toString().padStart(2, '0');
return mins + ':' + secs.toString().padStart(2, '0');
},
// --- Actions ---
resign() {
if (this.isGameOver) return;
if (!confirm('هل تريد الاستسلام؟')) return;
App.fetch('/api/multiplayer', {
method: 'POST',
body: JSON.stringify({ action: 'resign', match_id: this.matchId })
});
},
offerDraw() {
if (this.isGameOver) return;
App.fetch('/api/multiplayer', {
method: 'POST',
body: JSON.stringify({ action: 'draw_offer', match_id: this.matchId })
});
App.toast('تم ارسال عرض التعادل', 'info');
},
acceptDraw() {
App.fetch('/api/multiplayer', {
method: 'POST',
body: JSON.stringify({ action: 'draw_accept', match_id: this.matchId })
});
},
declineDraw() {
App.fetch('/api/multiplayer', {
method: 'POST',
body: JSON.stringify({ action: 'draw_decline', match_id: this.matchId })
});
this.showDrawOffer(false);
},
showDrawOffer(show) {
['draw-offer-banner', 'draw-offer-banner-mobile'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = show ? 'flex' : 'none';
});
},
// --- Move list ---
updateMoveList() {
const lists = [document.getElementById('move-list'), document.getElementById('move-list-mobile')].filter(Boolean);
const history = this.moveHistory;
lists.forEach(list => {
list.innerHTML = '';
for (let i = 0; i < history.length; i += 2) {
const pair = document.createElement('div');
pair.className = 'move-pair';
const num = document.createElement('span');
num.className = 'move-number';
num.textContent = (Math.floor(i / 2) + 1) + '.';
pair.appendChild(num);
const white = document.createElement('span');
white.className = 'move' + (i === this.currentMoveIndex ? ' current' : '');
white.textContent = history[i];
pair.appendChild(white);
if (history[i + 1]) {
const black = document.createElement('span');
black.className = 'move' + (i + 1 === this.currentMoveIndex ? ' current' : '');
black.textContent = history[i + 1];
pair.appendChild(black);
}
list.appendChild(pair);
}
list.scrollTop = list.scrollHeight;
});
this.updateOpeningName(history);
},
updateOpeningName(history) {
const els = [document.getElementById('opening-display'), document.getElementById('opening-display-mobile')].filter(Boolean);
if (!history.length) { els.forEach(el => el.style.display = 'none'); return; }
let opening = '';
for (let len = Math.min(history.length, 10); len >= 1; len--) {
const seq = history.slice(0, len).join(' ');
if (this.openings[seq]) { opening = this.openings[seq]; break; }
}
els.forEach(el => {
if (opening) {
el.style.display = 'flex';
const nameEl = el.querySelector('.opening-name');
if (nameEl) nameEl.textContent = opening;
} else {
el.style.display = 'none';
}
});
},
updateStatus(text) {
['game-status', 'game-status-mobile'].forEach(id => {
const el = document.getElementById(id);
if (el) el.textContent = text;
});
},
showThinking(show) {
const el = document.getElementById('thinking-indicator');
if (el) el.style.display = show ? 'flex' : 'none';
},
// --- Export ---
exportPGN() {
const pgn = this.chess.pgn();
if (navigator.clipboard) {
navigator.clipboard.writeText(pgn).then(() => App.toast('PGN copied', 'success'));
}
},
copyFEN() {
const fen = this.chess.fen();
if (navigator.clipboard) {
navigator.clipboard.writeText(fen).then(() => App.toast('FEN copied', 'success'));
}
},
// --- Sound ---
getAudioCtx() {
if (!this.audioCtx) {
try { this.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); }
catch(e) { return null; }
}
return this.audioCtx;
},
playTone(freq, duration, gain, type) {
const ctx = this.getAudioCtx();
if (!ctx) return;
try {
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.connect(g); g.connect(ctx.destination);
osc.type = type || 'sine';
osc.frequency.setValueAtTime(freq, ctx.currentTime);
g.gain.setValueAtTime(gain || 0.1, ctx.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + duration);
} catch(e) {}
},
playMoveSound(move) {
if (move.san === 'O-O' || move.san === 'O-O-O') {
this.playTone(500, 0.06, 0.12, 'sine');
setTimeout(() => this.playTone(600, 0.06, 0.12, 'sine'), 70);
} else if (move.san && move.san.includes('+')) {
this.playTone(880, 0.15, 0.15, 'square');
} else if (move.captured) {
this.playTone(180, 0.12, 0.18, 'sawtooth');
} else {
this.playTone(440, 0.06, 0.1, 'sine');
}
},
playGameEndSound(isWin) {
if (isWin) {
this.playTone(523, 0.15, 0.12, 'sine');
setTimeout(() => this.playTone(659, 0.15, 0.12, 'sine'), 150);
setTimeout(() => this.playTone(784, 0.3, 0.15, 'sine'), 300);
} else {
this.playTone(400, 0.2, 0.1, 'sine');
setTimeout(() => this.playTone(300, 0.2, 0.1, 'sine'), 200);
}
}
};
// EL3AB Realtime - Supabase Realtime client via raw WebSocket (no npm)
const Realtime = {
ws: null,
token: null,
topic: null,
callback: null,
heartbeatInterval: null,
heartbeatRef: 0,
awaitingHeartbeat: false,
reconnectAttempts: 0,
maxReconnectAttempts: 5,
reconnectTimer: null,
matchId: null,
joined: false,
ENDPOINT: 'wss://safe-supabase-kong.caprover.al-arcade.com/realtime/v1/websocket',
ANON_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjE4OTM0NTYwMDB9.31PF6PvP-pSrvRuQwLFptQoejR0W1A7o53lZhEbnz84',
HEARTBEAT_MS: 30000,
connect(token) {
this.token = token;
this.reconnectAttempts = 0;
this._open();
},
_open() {
if (this.ws) {
this.ws.onclose = null;
this.ws.close();
}
const url = this.ENDPOINT + '?apikey=' + this.ANON_KEY + '&vsn=1.0.0';
this.ws = new WebSocket(url);
this.joined = false;
this.ws.onopen = () => {
console.log('[Realtime] connected');
this.reconnectAttempts = 0;
this._startHeartbeat();
if (this.matchId && this.callback) {
this._join();
}
};
this.ws.onmessage = (evt) => {
this._handleMessage(evt);
};
this.ws.onclose = () => {
console.log('[Realtime] disconnected');
this._stopHeartbeat();
this.joined = false;
this._scheduleReconnect();
};
this.ws.onerror = (err) => {
console.error('[Realtime] error', err);
};
},
_handleMessage(evt) {
let msg;
try {
msg = JSON.parse(evt.data);
} catch (e) {
return;
}
// Heartbeat reply
if (msg.topic === 'phoenix' && msg.event === 'phx_reply') {
this.awaitingHeartbeat = false;
return;
}
// Join reply
if (msg.event === 'phx_reply' && msg.topic === this.topic) {
if (msg.payload && msg.payload.status === 'ok') {
this.joined = true;
console.log('[Realtime] subscribed to', this.topic);
} else {
console.error('[Realtime] join failed', msg.payload);
}
return;
}
// Postgres changes event
if (msg.event === 'postgres_changes' && msg.topic === this.topic) {
const payload = msg.payload;
if (payload && payload.data && payload.data.type === 'UPDATE' && payload.data.record) {
if (this.callback) {
this.callback(payload.data.record);
}
}
return;
}
},
_startHeartbeat() {
this._stopHeartbeat();
this.heartbeatInterval = setInterval(() => {
if (this.awaitingHeartbeat) {
console.warn('[Realtime] heartbeat timeout, reconnecting');
this.ws.close();
return;
}
this.awaitingHeartbeat = true;
this.heartbeatRef++;
this._send({
topic: 'phoenix',
event: 'heartbeat',
payload: {},
ref: String(this.heartbeatRef)
});
}, this.HEARTBEAT_MS);
},
_stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
this.awaitingHeartbeat = false;
},
_scheduleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('[Realtime] max reconnect attempts reached');
return;
}
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
this.reconnectAttempts++;
console.log('[Realtime] reconnecting in', delay, 'ms (attempt', this.reconnectAttempts + ')');
this.reconnectTimer = setTimeout(() => {
this._open();
}, delay);
},
_join() {
const topic = 'realtime:public:matches:id=eq.' + this.matchId;
this.topic = topic;
this._send({
topic: topic,
event: 'phx_join',
payload: {
config: {
broadcast: { self: false },
presence: { key: '' },
postgres_changes: [{
event: 'UPDATE',
schema: 'public',
table: 'matches',
filter: 'id=eq.' + this.matchId
}]
},
access_token: this.token
},
ref: '1'
});
},
_send(msg) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg));
}
},
subscribe(matchId, callback) {
this.matchId = matchId;
this.callback = callback;
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this._join();
}
},
unsubscribe() {
if (this.topic && this.ws && this.ws.readyState === WebSocket.OPEN) {
this._send({
topic: this.topic,
event: 'phx_leave',
payload: {},
ref: '2'
});
}
this.topic = null;
this.matchId = null;
this.callback = null;
this.joined = false;
},
disconnect() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this._stopHeartbeat();
this.reconnectAttempts = this.maxReconnectAttempts; // prevent auto-reconnect
if (this.ws) {
this.ws.onclose = null;
this.ws.close();
this.ws = null;
}
this.topic = null;
this.matchId = null;
this.callback = null;
this.joined = false;
this.token = null;
console.log('[Realtime] closed');
}
};
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