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']);
}
This diff is collapsed.
...@@ -13,6 +13,10 @@ if ($route === '' || $route === 'home') { ...@@ -13,6 +13,10 @@ if ($route === '' || $route === 'home') {
require 'pages/play.php'; require 'pages/play.php';
} elseif ($route === 'game') { } elseif ($route === 'game') {
require 'pages/game.php'; require 'pages/game.php';
} elseif ($route === 'game-live') {
require 'pages/game-live.php';
} elseif ($route === 'matchmaking') {
require 'pages/matchmaking.php';
} elseif ($route === 'bots') { } elseif ($route === 'bots') {
require 'pages/bots.php'; require 'pages/bots.php';
} elseif ($route === 'profile') { } 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 @@ ...@@ -11,6 +11,33 @@
<!-- Game Mode Selection --> <!-- Game Mode Selection -->
<div class="space-y-3"> <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 --> <!-- VS Bot -->
<a href="/bots" class="card card-hover" style="display:block;text-decoration:none;"> <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;"> <div class="card-body" style="display:flex;align-items:center;gap:16px;">
...@@ -25,14 +52,14 @@ ...@@ -25,14 +52,14 @@
</div> </div>
</a> </a>
<!-- Quick Match --> <!-- Quick Match vs Bot -->
<div class="card card-hover" style="cursor:pointer;" onclick="startQuickMatch()"> <div class="card card-hover" style="cursor:pointer;" onclick="startQuickMatch()">
<div class="card-body" style="display:flex;align-items:center;gap:16px;"> <div class="card-body" style="display:flex;align-items:center;gap:16px;">
<div class="avatar" style="background:var(--bg-3);"> <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>
<div style="flex:1;"> <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> <p class="text-muted text-sm">5 دقائق ضد بوت عشوائي</p>
</div> </div>
<svg class="icon" style="color:var(--text-3);transform:scaleX(-1)"><use href="/public/icons/sprite.svg#icon-arrow-right"></use></svg> <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() { ...@@ -127,6 +154,14 @@ function getSelectedColor() {
return color; 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() { function startQuickMatch() {
const bots = ['amina','tarek','nour','omar','layla','ziad']; const bots = ['amina','tarek','nour','omar','layla','ziad'];
const bot = bots[Math.floor(Math.random() * bots.length)]; const bot = bots[Math.floor(Math.random() * bots.length)];
......
...@@ -397,6 +397,24 @@ ...@@ -397,6 +397,24 @@
min-width: 0; 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 */ /* Post-game Controls */
.postgame-controls { .postgame-controls {
display: flex; display: flex;
......
...@@ -95,7 +95,6 @@ const Board = { ...@@ -95,7 +95,6 @@ const Board = {
const settings = this.getSettings(); const settings = this.getSettings();
if (settings.coords === false) return; if (settings.coords === false) return;
// Remove old coords
if (this.coordsFile) this.coordsFile.remove(); if (this.coordsFile) this.coordsFile.remove();
if (this.coordsRank) this.coordsRank.remove(); if (this.coordsRank) this.coordsRank.remove();
......
This diff is collapsed.
// 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