Commit 93eeb5c8 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix: multiplayer sync — Ludo turn passing + Chess check/SFX for opponent moves

Ludo multiplayer:
- Sync state to server on EVERY turn change (including no-valid-moves pass)
- Use turn_count to detect stale vs fresh server state (prevents re-processing)
- Non-host players poll at 1.5s and receive dice rolls + board state
- Bot turns run only on host, results synced to server for other players
- Fix double-encoding of game_state/positions in API
- Play notification sound when turn returns to player

Chess multiplayer:
- Detect move type (check/capture/castle) from FEN diff on received moves
- Show check highlight (red king square) for opponent's checking moves
- Play correct SFX (check/capture/castle) instead of generic 'move'
- Show last-move highlight squares for received moves
- Skip polling during recovery to prevent SFX burst on reconnect
- Track captured pieces from opponent's moves
- Sync move list from server history
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent f49fc5cb
...@@ -137,10 +137,16 @@ function handleLudoMove(string $userId, array $input): void { ...@@ -137,10 +137,16 @@ function handleLudoMove(string $userId, array $input): void {
$sdb = supabaseService(); $sdb = supabaseService();
$update = []; $update = [];
if (isset($input['positions'])) $update['positions'] = json_encode($input['positions']); if (isset($input['positions'])) {
$pos = $input['positions'];
$update['positions'] = is_string($pos) ? $pos : json_encode($pos);
}
if (isset($input['current_turn'])) $update['current_turn'] = intval($input['current_turn']); 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['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['game_state'])) {
$gs = $input['game_state'];
$update['game_state'] = is_string($gs) ? $gs : json_encode($gs);
}
if (isset($input['winners'])) $update['winners'] = json_encode($input['winners']); if (isset($input['winners'])) $update['winners'] = json_encode($input['winners']);
if (isset($input['status'])) $update['status'] = $input['status']; if (isset($input['status'])) $update['status'] = $input['status'];
......
...@@ -37,8 +37,9 @@ export function mountGame(el, params) { ...@@ -37,8 +37,9 @@ export function mountGame(el, params) {
// If recovering from a refresh, fetch current state from server and fix color // If recovering from a refresh, fetch current state from server and fix color
if (params.recovered && matchId) { if (params.recovered && matchId) {
gameState._recovering = true;
net.post('game.php', { action: 'get', match_id: matchId }).then(data => { net.post('game.php', { action: 'get', match_id: matchId }).then(data => {
if (!data || data.error) return; if (!data || data.error) { gameState._recovering = false; return; }
// If game already ended (opponent resigned while we were away) // If game already ended (opponent resigned while we were away)
if (data.status === 'completed') { if (data.status === 'completed') {
...@@ -79,6 +80,11 @@ export function mountGame(el, params) { ...@@ -79,6 +80,11 @@ export function mountGame(el, params) {
gameState.isPlayerTurn = turnFromFen === gameState.playerColor; gameState.isPlayerTurn = turnFromFen === gameState.playerColor;
if (!gameState.isPlayerTurn) clock.start(turnFromFen); if (!gameState.isPlayerTurn) clock.start(turnFromFen);
else clock.start(gameState.playerColor); else clock.start(gameState.playerColor);
// Show check highlight if king is in check
if (engine.isCheck()) {
board.setCheck(findKing(engine.turn()));
}
} }
// Restore clocks // Restore clocks
...@@ -86,7 +92,8 @@ export function mountGame(el, params) { ...@@ -86,7 +92,8 @@ export function mountGame(el, params) {
if (data.black_time_remaining_ms) clock.black = data.black_time_remaining_ms; if (data.black_time_remaining_ms) clock.black = data.black_time_remaining_ms;
if (board) board.draw(); if (board) board.draw();
}).catch(() => {}); gameState._recovering = false;
}).catch(() => { gameState._recovering = false; });
} }
el.innerHTML = ` el.innerHTML = `
...@@ -581,19 +588,19 @@ async function sendLiveMove(el) { ...@@ -581,19 +588,19 @@ async function sendLiveMove(el) {
} }
function startLivePolling(el) { function startLivePolling(el) {
lastKnownMoveCount = 0; if (lastKnownMoveCount === 0) lastKnownMoveCount = gameState.moveCount || 0;
livePoller = setInterval(async () => { livePoller = setInterval(async () => {
if (gameState.gameOver) { clearInterval(livePoller); return; } if (gameState.gameOver) { clearInterval(livePoller); return; }
if (gameState.isPlayerTurn) return; // Only poll when waiting for opponent if (gameState._recovering) return;
if (gameState.isPlayerTurn) return;
try { try {
const data = await net.post('game.php', { action: 'get', match_id: gameState.matchId }); const data = await net.post('game.php', { action: 'get', match_id: gameState.matchId });
if (!data || data.error) return; if (!data || data.error) return;
// Update connection status (opponent is alive if data comes back)
mp.updateConnectionStatus(true); mp.updateConnectionStatus(true);
// Check for synced emotes from opponent // Emotes
const myId = store.get('auth.userId'); const myId = store.get('auth.userId');
const emoteData = mp.checkForEmote(data.game_state, myId); const emoteData = mp.checkForEmote(data.game_state, myId);
if (emoteData) { if (emoteData) {
...@@ -603,37 +610,146 @@ function startLivePolling(el) { ...@@ -603,37 +610,146 @@ function startLivePolling(el) {
audio.play('notification'); audio.play('notification');
} }
// Check if new move arrived // New move arrived
if (data.move_count > lastKnownMoveCount) { if (data.move_count > lastKnownMoveCount) {
lastKnownMoveCount = data.move_count; lastKnownMoveCount = data.move_count;
const newFen = data.current_fen; const newFen = data.current_fen;
if (newFen && newFen !== engine.fen()) { if (newFen && newFen !== engine.fen()) {
const oldFen = engine.fen();
engine.load(newFen); engine.load(newFen);
// Determine what kind of move it was for SFX + highlights
const lastMoveSan = detectLastMoveSan(data, oldFen, newFen);
const moveSquares = detectLastMoveSquares(oldFen, newFen);
board.setPosition(newFen); board.setPosition(newFen);
if (moveSquares) {
board.setLastMove(moveSquares.from, moveSquares.to);
}
// Play appropriate sound
if (lastMoveSan && lastMoveSan.includes('#')) {
audio.play('gameOver', 'game');
juice.shake(el, 6, 400);
juice.hapticHeavy();
} else if (lastMoveSan && lastMoveSan.includes('+')) {
audio.play('check', 'game');
juice.shake(el, 3, 200);
juice.hapticMedium();
} else if (isCapture(oldFen, newFen)) {
audio.play('capture', 'game');
juice.shake(el, 2, 150);
juice.hapticLight();
} else if (lastMoveSan && (lastMoveSan === 'O-O' || lastMoveSan === 'O-O-O')) {
audio.play('castle', 'game');
} else {
audio.play('move', 'game'); audio.play('move', 'game');
}
// Check highlight
if (engine.isCheck()) {
board.setCheck(findKing(engine.turn()));
} else {
board.highlights.check = null;
board.draw();
}
// Sync clocks from server
if (data.white_time_remaining_ms) clock.white = data.white_time_remaining_ms;
if (data.black_time_remaining_ms) clock.black = data.black_time_remaining_ms;
clock.switch(); clock.switch();
gameState.isPlayerTurn = true; gameState.isPlayerTurn = true;
gameState.moveCount = data.move_count; gameState.moveCount = data.move_count;
// Update move list + captured pieces from server history
if (data.moves) {
try {
const moveHistory = typeof data.moves === 'string' ? JSON.parse(data.moves) : data.moves;
if (Array.isArray(moveHistory) && moveHistory.length > gameState.moveHistory.length) {
const newMove = moveHistory[moveHistory.length - 1];
if (newMove) {
gameState.moveHistory.push(newMove);
updateMoveList(el, newMove);
if (newMove.captured) {
gameState.capturedByOpponent.push(newMove.captured);
updateCapturedDisplay(el);
}
}
}
} catch (e) {}
}
if (engine.isGameOver()) { if (engine.isGameOver()) {
endGame(engine.getResult(gameState.playerColor), getEndReason()); endGame(engine.getResult(gameState.playerColor), getEndReason());
} }
} }
} }
// Check if game ended (opponent resigned, etc.) // Game ended (resign, abandon)
if (data.status === 'completed' && !gameState.gameOver) { if (data.status === 'completed' && !gameState.gameOver) {
const result = data.result; const result = data.result;
if (result) { if (result) {
const isWin = (result === 'white_wins' && gameState.playerColor === 'w') || const isWin = (result === 'white_wins' && gameState.playerColor === 'w') ||
(result === 'black_wins' && gameState.playerColor === 'b'); (result === 'black_wins' && gameState.playerColor === 'b');
endGame(isWin ? 'win' : 'loss', 'resign'); endGame(isWin ? 'win' : result === 'aborted' ? 'draw' : 'loss', 'resign');
} }
} }
} catch (e) {} } catch (e) {}
}, 2000); }, 2000);
} }
function detectLastMoveSan(data, oldFen, newFen) {
if (data.moves) {
try {
const moves = typeof data.moves === 'string' ? JSON.parse(data.moves) : data.moves;
if (Array.isArray(moves) && moves.length > 0) {
const last = moves[moves.length - 1];
return last?.san || null;
}
} catch (e) {}
}
return null;
}
function detectLastMoveSquares(oldFen, newFen) {
const oldPieces = parseFenPieces(oldFen);
const newPieces = parseFenPieces(newFen);
let from = null, to = null;
for (const sq of Object.keys(oldPieces)) {
if (!newPieces[sq] && oldPieces[sq]) from = sq;
}
for (const sq of Object.keys(newPieces)) {
if (!oldPieces[sq] || oldPieces[sq] !== newPieces[sq]) {
if (newPieces[sq]) to = sq;
}
}
if (from && to) return { from, to };
return null;
}
function parseFenPieces(fen) {
const pieces = {};
const rows = fen.split(' ')[0].split('/');
for (let r = 0; r < 8; r++) {
let col = 0;
for (const ch of rows[r]) {
if (ch >= '1' && ch <= '8') col += parseInt(ch);
else {
pieces[String.fromCharCode(97 + col) + String(8 - r)] = ch;
col++;
}
}
}
return pieces;
}
function isCapture(oldFen, newFen) {
const oldCount = oldFen.split(' ')[0].replace(/[0-9/]/g, '').length;
const newCount = newFen.split(' ')[0].replace(/[0-9/]/g, '').length;
return newCount < oldCount;
}
function stopLivePolling() { function stopLivePolling() {
if (livePoller) { clearInterval(livePoller); livePoller = null; } if (livePoller) { clearInterval(livePoller); livePoller = null; }
} }
......
...@@ -59,9 +59,11 @@ export function mountGame(el, params) { ...@@ -59,9 +59,11 @@ export function mountGame(el, params) {
game = rules.createGame(numPlayers); game = rules.createGame(numPlayers);
game.mode = mode; game.mode = mode;
game.turnCount = 0;
validMoves = []; validMoves = [];
diceAnimating = false; diceAnimating = false;
livePoller = null; lastSyncTurnCount = 0;
if (livePoller) { clearInterval(livePoller); livePoller = null; }
const player = store.get('player') || {}; const player = store.get('player') || {};
const panels = [0,1,2,3].map(i => { const panels = [0,1,2,3].map(i => {
...@@ -214,6 +216,7 @@ async function handleRoll(el) { ...@@ -214,6 +216,7 @@ async function handleRoll(el) {
updatePanels(el); updatePanels(el);
const dice = await animateDice(el, myPlayerIndex); const dice = await animateDice(el, myPlayerIndex);
game.diceValue = dice;
game.rolled = true; game.rolled = true;
diceAnimating = false; diceAnimating = false;
...@@ -225,7 +228,14 @@ async function handleRoll(el) { ...@@ -225,7 +228,14 @@ async function handleRoll(el) {
validMoves = rules.getValidMoves(game, myPlayerIndex, dice); validMoves = rules.getValidMoves(game, myPlayerIndex, dice);
if (validMoves.length === 0) { if (validMoves.length === 0) {
setTimeout(() => { game.rolled = false; rules.nextTurn(game); updatePanels(el); drawBoard(); if (!isMyTurn()) handleNonPlayerTurn(el); }, 800); setTimeout(() => {
game.rolled = false;
rules.nextTurn(game);
if (game.mode === 'live') syncLudoState(dice);
updatePanels(el);
drawBoard();
if (!isMyTurn()) handleNonPlayerTurn(el);
}, 800);
} else if (validMoves.length === 1) { } else if (validMoves.length === 1) {
highlightMovablePieces(validMoves); highlightMovablePieces(validMoves);
setTimeout(() => doMove(el, validMoves[0]), 600); setTimeout(() => doMove(el, validMoves[0]), 600);
...@@ -241,12 +251,12 @@ function doMove(el, move) { ...@@ -241,12 +251,12 @@ function doMove(el, move) {
function handleNonPlayerTurn(el) { function handleNonPlayerTurn(el) {
if (game.mode === 'live') { 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]; const currentPlayerStr = PLAYER_NAMES[game.currentPlayer];
if (currentPlayerStr && currentPlayerStr.startsWith('Bot') && isHost) { if (currentPlayerStr && currentPlayerStr.startsWith('Bot') && isHost) {
// Host runs bots locally and syncs result to server
setTimeout(() => botLoop(el), 400); setTimeout(() => botLoop(el), 400);
} else if (!isMyTurn()) { } else {
// Non-host (or waiting for human opponent): poll for state updates
startLudoPolling(el); startLudoPolling(el);
} }
} else { } else {
...@@ -293,9 +303,24 @@ async function botLoop(el) { ...@@ -293,9 +303,24 @@ async function botLoop(el) {
if (game.gameOver || isMyTurn()) return; if (game.gameOver || isMyTurn()) return;
// 4. Execute move with step animation // 4. Execute move with step animation
game.diceValue = dice;
const move = rules.getBotMove(game, game.currentPlayer, dice); const move = rules.getBotMove(game, game.currentPlayer, dice);
if (move) { if (!move) {
// Animate bot move step by step too // No valid moves — just pass turn
if (game.gameOver) {
if (game.mode === 'live' && isHost) syncLudoState(dice);
endGame(el);
return;
}
rules.nextTurn(game);
updatePanels(el);
drawBoard();
if (game.mode === 'live' && isHost) syncLudoState(dice);
if (!isMyTurn()) handleNonPlayerTurn(el);
return;
}
// Animate bot move step by step
const pIdx = parseInt(move.pieceId.split('-')[0]); const pIdx = parseInt(move.pieceId.split('-')[0]);
const pieceIdx = parseInt(move.pieceId.split('-')[1]); const pieceIdx = parseInt(move.pieceId.split('-')[1]);
const piece = game.players[pIdx].pieces[pieceIdx]; const piece = game.players[pIdx].pieces[pieceIdx];
...@@ -303,14 +328,13 @@ async function botLoop(el) { ...@@ -303,14 +328,13 @@ async function botLoop(el) {
const toPos = move.to; const toPos = move.to;
if (move.type !== 'enter' && fromPos >= 0 && toPos > fromPos && toPos - fromPos <= 6) { if (move.type !== 'enter' && fromPos >= 0 && toPos > fromPos && toPos - fromPos <= 6) {
// Step by step
for (let i = 1; i <= toPos - fromPos; i++) { for (let i = 1; i <= toPos - fromPos; i++) {
piece.pos = fromPos + i; piece.pos = fromPos + i;
drawBoard(); drawBoard();
audio.play('move', 'game'); audio.play('move', 'game');
await new Promise(r => setTimeout(r, 100)); await new Promise(r => setTimeout(r, 100));
} }
piece.pos = fromPos; // Reset for proper rules application piece.pos = fromPos;
} }
rules.applyMove(game, game.currentPlayer, move); rules.applyMove(game, game.currentPlayer, move);
...@@ -322,66 +346,116 @@ async function botLoop(el) { ...@@ -322,66 +346,116 @@ async function botLoop(el) {
audio.play('win', 'reward'); audio.play('win', 'reward');
} }
// 5. Bot occasionally sends emote on exciting events
if (move.type === 'capture' && Math.random() > 0.5) { if (move.type === 'capture' && Math.random() > 0.5) {
const emoteWrap = el.querySelector('#ludo-wrap'); const emoteWrap = el.querySelector('#ludo-wrap');
setTimeout(() => { setTimeout(() => {
const botEmotes = ['😂', '💪', '🎉', '😎']; const botEmotes = ['😂', '💪', '🎉', '😎'];
const botIdx = game.currentPlayerIndex; const botPanel2 = el.querySelector(`#pp-${game.currentPlayer}`);
const botPanel = el.querySelector(`#pp-${botIdx}`); emoteSystem.showReceived(emoteWrap, botEmotes[Math.floor(Math.random() * botEmotes.length)], botPanel2);
emoteSystem.showReceived(emoteWrap, botEmotes[Math.floor(Math.random() * botEmotes.length)], botPanel);
}, 300); }, 300);
} }
}
if (game.gameOver) { endGame(el); return; } if (game.gameOver) {
if (game.mode === 'live' && isHost) syncLudoState(dice);
endGame(el);
return;
}
rules.nextTurn(game); rules.nextTurn(game);
updatePanels(el); updatePanels(el);
drawBoard(); drawBoard();
// After bot, sync state if live + host if (game.mode === 'live' && isHost) syncLudoState(dice);
if (game.mode === 'live' && isHost) syncLudoState();
updatePanels(el);
if (!isMyTurn()) handleNonPlayerTurn(el); if (!isMyTurn()) handleNonPlayerTurn(el);
} }
// === LIVE SYNC === // === LIVE SYNC ===
function syncLudoState() { let lastSyncTurnCount = 0;
function syncLudoState(diceRolled = null) {
if (!matchId) return; if (!matchId) return;
const positions = game.players.map(p => ({ pos: p.pieces.map(pc => pc.pos) })); const turnCount = (game.turnCount || 0) + 1;
net.post('ludo-match.php', { game.turnCount = turnCount;
const positions = game.players.map(p => ({
pos: p.pieces.map(pc => pc.pos),
finished: p.finished
}));
const payload = {
action: 'move', action: 'move',
match_id: matchId, match_id: matchId,
positions, positions,
current_turn: game.currentPlayer, current_turn: game.currentPlayer,
dice_value: game.diceValue, dice_value: game.diceValue,
game_state: { turn_count: (game.turnCount || 0) + 1 } game_state: JSON.stringify({ turn_count: turnCount, dice_rolled: diceRolled }),
}).catch(() => {}); };
if (game.gameOver) {
payload.status = 'completed';
payload.winners = game.winners;
}
net.post('ludo-match.php', payload).catch(() => {});
} }
function startLudoPolling(el) { function startLudoPolling(el) {
if (livePoller) return; if (livePoller) return;
livePoller = setInterval(async () => { livePoller = setInterval(async () => {
if (game.gameOver || isMyTurn()) { clearInterval(livePoller); livePoller = null; return; } if (game.gameOver) { clearInterval(livePoller); livePoller = null; return; }
if (isMyTurn()) { clearInterval(livePoller); livePoller = null; return; }
try { try {
const data = await net.post('ludo-match.php', { action: 'get', match_id: matchId }); const data = await net.post('ludo-match.php', { action: 'get', match_id: matchId });
if (!data || data.error) return; if (!data || data.error) return;
// Check if turn changed to us
if (data.current_turn === myPlayerIndex) { // Parse game_state to get turn_count
clearInterval(livePoller); livePoller = null; let gs = {};
// Apply remote state try { gs = typeof data.game_state === 'string' ? JSON.parse(data.game_state) : (data.game_state || {}); } catch(e) {}
const positions = JSON.parse(typeof data.positions === 'string' ? data.positions : JSON.stringify(data.positions)); const remoteTurnCount = gs.turn_count || 0;
// Only apply if server state has advanced
if (remoteTurnCount <= lastSyncTurnCount) return;
lastSyncTurnCount = remoteTurnCount;
// Apply remote state — positions, turn, dice
const positions = typeof data.positions === 'string' ? JSON.parse(data.positions) : data.positions;
if (positions) {
positions.forEach((p, pIdx) => { positions.forEach((p, pIdx) => {
p.pos.forEach((pos, pieceIdx) => { game.players[pIdx].pieces[pieceIdx].pos = pos; }); if (p.pos) p.pos.forEach((pos, pieceIdx) => { game.players[pIdx].pieces[pieceIdx].pos = pos; });
if (p.finished !== undefined) game.players[pIdx].finished = p.finished;
}); });
}
game.currentPlayer = data.current_turn; game.currentPlayer = data.current_turn;
game.diceValue = data.dice_value; game.diceValue = data.dice_value;
game.turnCount = remoteTurnCount;
// Show opponent's dice result briefly
if (gs.dice_rolled != null) {
const mainDice = el.querySelector('#dice-box');
if (mainDice) renderDiceFace(mainDice, gs.dice_rolled);
}
// Check if game ended
if (data.status === 'completed' && !game.gameOver) {
game.gameOver = true;
clearInterval(livePoller); livePoller = null;
endGame(el);
return;
}
updatePanels(el); updatePanels(el);
drawBoard(); drawBoard();
// If it's now my turn, stop polling
if (isMyTurn()) {
clearInterval(livePoller); livePoller = null;
audio.play('notification');
} else {
// If it's a bot's turn and I'm host, run it
const currentPlayerStr = PLAYER_NAMES[game.currentPlayer];
if (currentPlayerStr && currentPlayerStr.startsWith('Bot') && isHost) {
clearInterval(livePoller); livePoller = null;
setTimeout(() => botLoop(el), 400);
}
} }
} catch (e) {} } catch (e) {}
}, 2000); }, 1500);
} }
// ===== OPPONENT POPUP ===== // ===== OPPONENT POPUP =====
...@@ -529,12 +603,16 @@ function afterMove(el, move) { ...@@ -529,12 +603,16 @@ function afterMove(el, move) {
juice.starBurst(boardSize/2 + boardRect.left, boardSize/2 + boardRect.top, 10); juice.starBurst(boardSize/2 + boardRect.left, boardSize/2 + boardRect.top, 10);
} }
if (game.gameOver) { endGame(el); return; } if (game.gameOver) {
if (game.mode === 'live') syncLudoState(game.diceValue);
endGame(el);
return;
}
rules.nextTurn(game); rules.nextTurn(game);
updatePanels(el); updatePanels(el);
drawBoard(); drawBoard();
if (game.mode === 'live') syncLudoState(); if (game.mode === 'live') syncLudoState(game.diceValue);
if (!isMyTurn()) handleNonPlayerTurn(el); if (!isMyTurn()) handleNonPlayerTurn(el);
} }
......
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