Commit d6b1ca39 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: Ludo multiplayer — 2 humans + 2 bots, matchmaking + realtime sync

New API: /api/ludo-match.php
- queue: searches ludo_queue for opponent, creates match with 2 humans + 2 bots
- status: polls for match_id when opponent joins
- move: syncs game state (positions, turn, dice)
- get: fetches current match state

Ludo game scene updated:
- Stores mode, myPlayerIndex, matchId, isHost
- isMyTurn() checks player_index vs current_turn
- handleNonPlayerTurn(): routes to bot (if host) or polling (if waiting)
- syncLudoState(): sends positions/turn/dice to server after every move
- startLudoPolling(): polls every 2s for opponent moves, applies state
- Bot turns run by host only (prevents duplicate bot moves)
- Player names show 'أنت' for self, 'لاعب X' for other human, 'Bot X' for bots

Queue scene updated:
- Uses ludo-match.php endpoint for Ludo games
- Passes playerIndex and players array to game scene
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent ae7f8586
<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
$token = requireAuth();
$userId = getUserId($token);
$input = getInput();
$action = $input['action'] ?? '';
switch ($action) {
case 'queue': handleLudoQueue($userId, $input); break;
case 'status': handleLudoStatus($userId); break;
case 'move': handleLudoMove($userId, $input); break;
case 'get': handleLudoGet($userId, $input); break;
default: jsonError('Invalid action');
}
function handleLudoQueue(string $userId, array $input): void {
$sdb = supabaseService();
// Clean old waiting entries for this player
$sdb->delete('ludo_queue', ['user_id' => 'eq.' . $userId]);
// Check for waiting opponent
$searchUrl = SUPABASE_REST . '/ludo_queue'
. '?user_id=neq.' . $userId
. '&match_id=is.null'
. '&select=id,user_id'
. '&limit=1';
$ch = curl_init($searchUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'apikey: ' . SUPABASE_SERVICE_KEY,
'Authorization: Bearer ' . SUPABASE_SERVICE_KEY,
'Content-Type: application/json'
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$result = curl_exec($ch);
curl_close($ch);
$opponents = json_decode($result, true);
if (!empty($opponents) && isset($opponents[0])) {
$opponent = $opponents[0];
// Create ludo match: 2 humans (player 0 = opponent who waited, player 1 = this player) + 2 bots
$players = [$opponent['user_id'], $userId, 'bot_1', 'bot_2'];
$matchData = [
'room_code' => strtoupper(substr(bin2hex(random_bytes(3)), 0, 6)),
'status' => 'in_progress',
'player_count' => 4,
'players' => json_encode($players),
'current_turn' => 0,
'dice_value' => null,
'positions' => json_encode([
['pos' => [-1,-1,-1,-1]],
['pos' => [-1,-1,-1,-1]],
['pos' => [-1,-1,-1,-1]],
['pos' => [-1,-1,-1,-1]]
]),
'moves' => '[]',
'winners' => '[]',
'game_state' => json_encode(['turn_count' => 0]),
'host_id' => $opponent['user_id']
];
$match = $sdb->insert('ludo_matches', $matchData);
$matchId = $match[0]['id'] ?? $match['id'] ?? null;
if ($matchId) {
// Mark opponent's queue entry with match_id
$sdb->update('ludo_queue', ['match_id' => $matchId], ['id' => 'eq.' . $opponent['id']]);
jsonResponse([
'match_id' => $matchId,
'player_index' => 1,
'players' => $players
]);
}
}
// No opponent found — add to queue
$sdb->insert('ludo_queue', ['user_id' => $userId]);
jsonResponse(['queued' => true]);
}
function handleLudoStatus(string $userId): void {
$sdb = supabaseService();
$entry = $sdb->get('ludo_queue', [
'user_id' => 'eq.' . $userId,
'select' => 'id,match_id',
'limit' => 1
]);
if (!empty($entry) && !isset($entry['error']) && !empty($entry[0]['match_id'])) {
$matchId = $entry[0]['match_id'];
// Clean queue
$sdb->delete('ludo_queue', ['user_id' => 'eq.' . $userId]);
// Get match to find player index
$matches = $sdb->get('ludo_matches', ['id' => 'eq.' . $matchId, 'select' => 'players', 'limit' => 1]);
$players = [];
if (!empty($matches) && !isset($matches['error'])) {
$players = json_decode($matches[0]['players'] ?? '[]', true);
}
$playerIndex = array_search($userId, $players);
jsonResponse([
'match_id' => $matchId,
'player_index' => $playerIndex !== false ? $playerIndex : 0,
'players' => $players
]);
}
jsonResponse(['waiting' => true]);
}
function handleLudoMove(string $userId, array $input): void {
$matchId = $input['match_id'] ?? '';
if (!$matchId) jsonError('match_id required');
$sdb = supabaseService();
$update = [];
if (isset($input['positions'])) $update['positions'] = json_encode($input['positions']);
if (isset($input['current_turn'])) $update['current_turn'] = intval($input['current_turn']);
if (isset($input['dice_value'])) $update['dice_value'] = intval($input['dice_value']);
if (isset($input['game_state'])) $update['game_state'] = json_encode($input['game_state']);
if (isset($input['winners'])) $update['winners'] = json_encode($input['winners']);
if (isset($input['status'])) $update['status'] = $input['status'];
if (!empty($update)) {
$sdb->update('ludo_matches', $update, ['id' => 'eq.' . $matchId]);
}
jsonResponse(['success' => true]);
}
function handleLudoGet(string $userId, array $input): void {
$matchId = $input['match_id'] ?? ($_GET['match_id'] ?? '');
if (!$matchId) jsonError('match_id required');
$sdb = supabaseService();
$matches = $sdb->get('ludo_matches', ['id' => 'eq.' . $matchId, 'select' => '*', 'limit' => 1]);
if (empty($matches) || isset($matches['error'])) jsonError('Not found', 404);
jsonResponse($matches[0]);
}
...@@ -14,15 +14,35 @@ let diceAnimating = false; ...@@ -14,15 +14,35 @@ let diceAnimating = false;
const COLORS = ['#E53935', '#1E88E5', '#43A047', '#FDD835']; const COLORS = ['#E53935', '#1E88E5', '#43A047', '#FDD835'];
const COLORS_LIGHT = ['#EF9A9A', '#90CAF9', '#A5D6A7', '#FFF59D']; const COLORS_LIGHT = ['#EF9A9A', '#90CAF9', '#A5D6A7', '#FFF59D'];
const PLAYER_NAMES = ['أنت', 'Bot 1', 'Bot 2', 'Bot 3']; let PLAYER_NAMES = ['أنت', 'Bot 1', 'Bot 2', 'Bot 3'];
let livePoller = null;
let myPlayerIndex = 0;
let matchId = null;
let isHost = false;
export function mountGame(el, params) { export function mountGame(el, params) {
const { mode = 'bot', numPlayers = 4 } = params; const { mode = 'bot', numPlayers = 4 } = params;
scene.enterGameMode(); scene.enterGameMode();
myPlayerIndex = params.playerIndex || 0;
matchId = params.matchId || null;
isHost = myPlayerIndex === 0;
if (mode === 'live' && params.players) {
PLAYER_NAMES = params.players.map((p, i) => {
if (i === myPlayerIndex) return 'أنت';
if (p.startsWith('bot')) return 'Bot ' + p.split('_')[1];
return 'لاعب ' + (i + 1);
});
} else {
PLAYER_NAMES = ['أنت', 'Bot 1', 'Bot 2', 'Bot 3'];
}
game = rules.createGame(numPlayers); game = rules.createGame(numPlayers);
game.mode = mode;
validMoves = []; validMoves = [];
diceAnimating = false; diceAnimating = false;
livePoller = null;
el.innerHTML = ` el.innerHTML = `
<div style="display:flex;flex-direction:column;height:100%;background:#1a1a2e;"> <div style="display:flex;flex-direction:column;height:100%;background:#1a1a2e;">
...@@ -88,8 +108,12 @@ function renderDiceFace(diceBox, value) { ...@@ -88,8 +108,12 @@ function renderDiceFace(diceBox, value) {
).join(''); ).join('');
} }
function isMyTurn() {
return game.currentPlayer === myPlayerIndex;
}
function handleRoll(el) { function handleRoll(el) {
if (diceAnimating || game.rolled || game.gameOver || game.currentPlayer !== 0) return; if (diceAnimating || game.rolled || game.gameOver || !isMyTurn()) return;
diceAnimating = true; diceAnimating = true;
const btn = el.querySelector('#roll-btn'); const btn = el.querySelector('#roll-btn');
const diceBox = el.querySelector('#dice-box'); const diceBox = el.querySelector('#dice-box');
...@@ -134,7 +158,7 @@ function handleRoll(el) { ...@@ -134,7 +158,7 @@ function handleRoll(el) {
validMoves = rules.getValidMoves(game, 0, dice); validMoves = rules.getValidMoves(game, 0, dice);
if (validMoves.length === 0) { if (validMoves.length === 0) {
setTimeout(() => { game.rolled = false; rules.nextTurn(game); btn.disabled = false; btn.style.opacity = '1'; updatePanels(el); drawBoard(); if (game.currentPlayer !== 0) botLoop(el); }, 800); setTimeout(() => { game.rolled = false; rules.nextTurn(game); btn.disabled = false; btn.style.opacity = '1'; updatePanels(el); drawBoard(); if (!isMyTurn()) handleNonPlayerTurn(el); }, 800);
} else { } else {
const best = validMoves.find(m=>m.type==='capture') || validMoves.find(m=>m.type==='finish') || validMoves.find(m=>m.type==='enter') || validMoves[0]; const best = validMoves.find(m=>m.type==='capture') || validMoves.find(m=>m.type==='finish') || validMoves.find(m=>m.type==='enter') || validMoves[0];
setTimeout(() => doMove(el, best), 400); setTimeout(() => doMove(el, best), 400);
...@@ -154,13 +178,33 @@ function doMove(el, move) { ...@@ -154,13 +178,33 @@ function doMove(el, move) {
rules.nextTurn(game); rules.nextTurn(game);
updatePanels(el); updatePanels(el);
drawBoard(); drawBoard();
// Sync state after own move in live mode
if (game.mode === 'live') syncLudoState();
const btn = el.querySelector('#roll-btn'); const btn = el.querySelector('#roll-btn');
if (game.currentPlayer === 0) { btn.disabled = false; btn.style.opacity = '1'; } if (isMyTurn()) { btn.disabled = false; btn.style.opacity = '1'; }
else setTimeout(() => botLoop(el), 400); else handleNonPlayerTurn(el);
}
function handleNonPlayerTurn(el) {
if (game.mode === 'live') {
// In live mode: if it's a bot turn AND we're the host, run the bot
// If it's the other human's turn, poll for their move
const currentPlayerStr = PLAYER_NAMES[game.currentPlayer];
if (currentPlayerStr && currentPlayerStr.startsWith('Bot') && isHost) {
setTimeout(() => botLoop(el), 400);
} else if (!isMyTurn()) {
startLudoPolling(el);
}
} else {
// Single player — all non-player turns are bots
setTimeout(() => botLoop(el), 400);
}
} }
function botLoop(el) { function botLoop(el) {
if (game.gameOver || game.currentPlayer === 0) return; if (game.gameOver || isMyTurn()) return;
const dice = rules.rollDice(); const dice = rules.rollDice();
game.diceValue = dice; game.diceValue = dice;
const diceBox = el.querySelector('#dice-box'); const diceBox = el.querySelector('#dice-box');
...@@ -175,8 +219,52 @@ function botLoop(el) { ...@@ -175,8 +219,52 @@ function botLoop(el) {
rules.nextTurn(game); rules.nextTurn(game);
updatePanels(el); updatePanels(el);
drawBoard(); drawBoard();
if (game.currentPlayer !== 0) setTimeout(() => botLoop(el), 350);
else el.querySelector('#roll-btn').disabled = false; // After bot, sync state if live + host
if (game.mode === 'live' && isHost) syncLudoState();
if (!isMyTurn()) handleNonPlayerTurn(el);
else { el.querySelector('#roll-btn').disabled = false; el.querySelector('#roll-btn').style.opacity = '1'; }
}
// === LIVE SYNC ===
function syncLudoState() {
if (!matchId) return;
const positions = game.players.map(p => ({ pos: p.pieces.map(pc => pc.pos) }));
net.post('ludo-match.php', {
action: 'move',
match_id: matchId,
positions,
current_turn: game.currentPlayer,
dice_value: game.diceValue,
game_state: { turn_count: (game.turnCount || 0) + 1 }
}).catch(() => {});
}
function startLudoPolling(el) {
if (livePoller) return;
livePoller = setInterval(async () => {
if (game.gameOver || isMyTurn()) { clearInterval(livePoller); livePoller = null; return; }
try {
const data = await net.post('ludo-match.php', { action: 'get', match_id: matchId });
if (!data || data.error) return;
// Check if turn changed to us
if (data.current_turn === myPlayerIndex) {
clearInterval(livePoller); livePoller = null;
// Apply remote state
const positions = JSON.parse(typeof data.positions === 'string' ? data.positions : JSON.stringify(data.positions));
positions.forEach((p, pIdx) => {
p.pos.forEach((pos, pieceIdx) => { game.players[pIdx].pieces[pieceIdx].pos = pos; });
});
game.currentPlayer = data.current_turn;
game.diceValue = data.dice_value;
updatePanels(el);
drawBoard();
el.querySelector('#roll-btn').disabled = false;
el.querySelector('#roll-btn').style.opacity = '1';
}
} catch (e) {}
}, 2000);
} }
function drawBoard() { function drawBoard() {
......
...@@ -45,11 +45,12 @@ export function mountQueue(el, params) { ...@@ -45,11 +45,12 @@ export function mountQueue(el, params) {
} }
async function joinQueue(params) { async function joinQueue(params) {
const endpoint = params.game === 'ludo' ? 'ludo-match.php' : 'matchmaking.php';
try { try {
const data = await net.post('matchmaking.php', { const data = await net.post(endpoint, {
action: 'queue', action: 'queue',
game_key: params.game, game_key: params.game,
time_control: params.timeControl time_control: params.timeControl || 'standard'
}); });
if (data.match_id) { if (data.match_id) {
...@@ -63,9 +64,10 @@ async function joinQueue(params) { ...@@ -63,9 +64,10 @@ async function joinQueue(params) {
} }
function pollForMatch(params) { function pollForMatch(params) {
const endpoint = params.game === 'ludo' ? 'ludo-match.php' : 'matchmaking.php';
unsub = setInterval(async () => { unsub = setInterval(async () => {
try { try {
const data = await net.post('matchmaking.php', { action: 'status', game_key: params.game }); const data = await net.post(endpoint, { action: 'status', game_key: params.game });
if (data.match_id) { if (data.match_id) {
onMatchFound(data, params); onMatchFound(data, params);
} }
...@@ -77,7 +79,14 @@ function onMatchFound(data, params) { ...@@ -77,7 +79,14 @@ function onMatchFound(data, params) {
cleanup(); cleanup();
audio.play('notification'); audio.play('notification');
const gameScene = params.game === 'chess' ? 'chess-game' : params.game + '-game'; const gameScene = params.game === 'chess' ? 'chess-game' : params.game + '-game';
scene.replace(gameScene, { ...params, matchId: data.match_id, mode: 'live', color: data.color }); scene.replace(gameScene, {
...params,
matchId: data.match_id,
mode: 'live',
color: data.color,
playerIndex: data.player_index,
players: data.players
});
} }
async function leaveQueue(params) { async function leaveQueue(params) {
......
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