implémentation d'une fin de partie
This commit is contained in:
parent
dd12b2c1a4
commit
7c16287d4d
2 changed files with 208 additions and 9 deletions
109
public/game.html
109
public/game.html
|
|
@ -6,6 +6,17 @@
|
||||||
<link rel="stylesheet" href="/styles/style.css">
|
<link rel="stylesheet" href="/styles/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<!-- Game over modal (hidden by default). Shown when server emits game:over -->
|
||||||
|
<div id="gameOverModal" style="position:fixed;left:0;top:0;right:0;bottom:0;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,0.45);z-index:9999">
|
||||||
|
<div style="background:#fff;padding:24px;border-radius:10px;min-width:260px;max-width:90%;text-align:center;box-shadow:0 8px 24px rgba(0,0,0,0.25)">
|
||||||
|
<h2 id="gameOverTitle" style="margin-top:0;margin-bottom:8px">Partie terminée</h2>
|
||||||
|
<div id="gameOverMessage" style="margin-bottom:12px">Chargement...</div>
|
||||||
|
<div style="display:flex;gap:8px;justify-content:center">
|
||||||
|
<button id="gameOverClose" style="background:#efefef;border:0;padding:8px 12px;border-radius:6px;cursor:pointer">Rester</button>
|
||||||
|
<button id="gameOverReload" style="background:linear-gradient(90deg,#ff7a59,#ffb199);border:0;color:#fff;padding:8px 12px;border-radius:6px;cursor:pointer">Quitter</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<h2>ChessNut — Partie</h2>
|
<h2>ChessNut — Partie</h2>
|
||||||
<div id="turnBanner" style="margin-bottom:8px"></div>
|
<div id="turnBanner" style="margin-bottom:8px"></div>
|
||||||
<div>Room: <strong id="roomId">-</strong> • Vous jouez: <strong id="myColor">-</strong>
|
<div>Room: <strong id="roomId">-</strong> • Vous jouez: <strong id="myColor">-</strong>
|
||||||
|
|
@ -53,6 +64,8 @@
|
||||||
let myPlayerId = playerId || null;
|
let myPlayerId = playerId || null;
|
||||||
// track remote selections: { playerId: square }
|
// track remote selections: { playerId: square }
|
||||||
const remoteSelections = {};
|
const remoteSelections = {};
|
||||||
|
// whether this client stayed to spectate after game over
|
||||||
|
let isSpectating = false;
|
||||||
// track remote moves: { playerId: [moves] }
|
// track remote moves: { playerId: [moves] }
|
||||||
const remoteMoves = {};
|
const remoteMoves = {};
|
||||||
// current boardState cached locally
|
// current boardState cached locally
|
||||||
|
|
@ -133,7 +146,7 @@
|
||||||
socket.on('connect', ()=>{ log('socket connecté', socket.id); });
|
socket.on('connect', ()=>{ log('socket connecté', socket.id); });
|
||||||
|
|
||||||
socket.on('room:update', (data)=>{
|
socket.on('room:update', (data)=>{
|
||||||
log('room:update', data);
|
log('room:update', data);
|
||||||
// server now sends `boardState` (JSON). renderBoardFromState will handle it.
|
// server now sends `boardState` (JSON). renderBoardFromState will handle it.
|
||||||
if(playersEl){
|
if(playersEl){
|
||||||
playersEl.innerHTML = '';
|
playersEl.innerHTML = '';
|
||||||
|
|
@ -155,6 +168,8 @@
|
||||||
renderBoardFromState(data.boardState);
|
renderBoardFromState(data.boardState);
|
||||||
updateTurnBanner(data.boardState);
|
updateTurnBanner(data.boardState);
|
||||||
}
|
}
|
||||||
|
// if the room is no longer finished, remove spectator quit button
|
||||||
|
try{ if(data && data.status && data.status !== 'finished'){ removeSpectatorQuitButton(); } }catch(_){ }
|
||||||
// store mines owned by this client (private)
|
// store mines owned by this client (private)
|
||||||
try{ myMines = data.minesOwn || []; }catch(e){ myMines = []; }
|
try{ myMines = data.minesOwn || []; }catch(e){ myMines = []; }
|
||||||
// after rendering the board, render mines for owner
|
// after rendering the board, render mines for owner
|
||||||
|
|
@ -188,7 +203,14 @@
|
||||||
}
|
}
|
||||||
}catch(_){ }
|
}catch(_){ }
|
||||||
});
|
});
|
||||||
socket.on('game:over', (data)=>{ log('game:over', data); if(data.boardState){ renderBoardFromState(data.boardState); updateTurnBanner(data.boardState); } });
|
socket.on('game:over', (data)=>{
|
||||||
|
try{
|
||||||
|
log('game:over', data);
|
||||||
|
if(data.boardState){ renderBoardFromState(data.boardState); updateTurnBanner(data.boardState); }
|
||||||
|
// show modal with friendly message
|
||||||
|
try{ showGameOver(data); }catch(_){ }
|
||||||
|
}catch(e){ console.warn('game:over handler error', e); }
|
||||||
|
});
|
||||||
socket.on('game:started', (data)=>{ log('game:started', data); if(data.boardState){ renderBoardFromState(data.boardState); updateTurnBanner(data.boardState); } });
|
socket.on('game:started', (data)=>{ log('game:started', data); if(data.boardState){ renderBoardFromState(data.boardState); updateTurnBanner(data.boardState); } });
|
||||||
// card drawn notification from server
|
// card drawn notification from server
|
||||||
socket.on('card:drawn', (data) => {
|
socket.on('card:drawn', (data) => {
|
||||||
|
|
@ -367,6 +389,89 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show the end-of-game modal with a helpful message and actions
|
||||||
|
function showGameOver(data){
|
||||||
|
try{
|
||||||
|
const modal = document.getElementById('gameOverModal');
|
||||||
|
const title = document.getElementById('gameOverTitle');
|
||||||
|
const msg = document.getElementById('gameOverMessage');
|
||||||
|
const close = document.getElementById('gameOverClose');
|
||||||
|
const reload = document.getElementById('gameOverReload');
|
||||||
|
if(!modal || !title || !msg) return;
|
||||||
|
// determine outcome
|
||||||
|
if(data && data.draw){
|
||||||
|
title.textContent = 'Égalité';
|
||||||
|
msg.textContent = 'La partie est terminée: aucun roi n\'est en jeu.';
|
||||||
|
} else if(data && data.winnerId){
|
||||||
|
const youAreWinner = (myPlayerId && data.winnerId && myPlayerId === data.winnerId);
|
||||||
|
if(youAreWinner){ title.textContent = 'Victoire 🎉'; msg.textContent = 'Félicitations — vous avez gagné !'; }
|
||||||
|
else { title.textContent = 'Défaite'; msg.textContent = 'Vous avez perdu — l\'adversaire a conservé son roi.'; }
|
||||||
|
} else if(data && data.winnerColor){
|
||||||
|
// fallback: present color-based message
|
||||||
|
const youWin = myColor && myColor[0] === data.winnerColor;
|
||||||
|
if(youWin){ title.textContent = 'Victoire 🎉'; msg.textContent = 'Félicitations — vous avez gagné !'; }
|
||||||
|
else { title.textContent = 'Défaite'; msg.textContent = 'Vous avez perdu — l\'adversaire a gagné.'; }
|
||||||
|
} else {
|
||||||
|
title.textContent = 'Partie terminée';
|
||||||
|
msg.textContent = 'La partie est terminée.';
|
||||||
|
}
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
// disable interactions with board by adding an overlay class (optionally style via CSS)
|
||||||
|
if(boardEl) boardEl.classList.add('disabled-during-gameover');
|
||||||
|
// wire actions: stay (close modal) or quit (leave room and go back to index)
|
||||||
|
close.onclick = ()=>{
|
||||||
|
modal.style.display = 'none';
|
||||||
|
try{ if(boardEl) boardEl.classList.remove('disabled-during-gameover'); }catch(_){ }
|
||||||
|
// mark that this user stayed to spectate and show a persistent Quit button
|
||||||
|
try{ isSpectating = true; createSpectatorQuitButton(); }catch(_){ }
|
||||||
|
};
|
||||||
|
reload.onclick = ()=>{
|
||||||
|
try{
|
||||||
|
// try to inform server that we voluntarily leave the room, then navigate away
|
||||||
|
if(socket && socket.connected){
|
||||||
|
socket.emit('room:leave', { roomId }, (resp)=>{
|
||||||
|
try{ window.location.href = '/'; }catch(_){ window.location.reload(); }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
}catch(e){ try{ window.location.href = '/'; }catch(_){ window.location.reload(); } }
|
||||||
|
};
|
||||||
|
}catch(e){ console.warn('showGameOver error', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// create a persistent 'Quitter' button for spectators (appears when user chose to stay)
|
||||||
|
function createSpectatorQuitButton(){
|
||||||
|
try{
|
||||||
|
removeSpectatorQuitButton(); // ensure only one
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.id = 'spectatorQuitBtn';
|
||||||
|
btn.textContent = 'Quitter la partie';
|
||||||
|
btn.style.position = 'fixed';
|
||||||
|
btn.style.right = '18px';
|
||||||
|
btn.style.bottom = '18px';
|
||||||
|
btn.style.zIndex = 9999;
|
||||||
|
btn.style.padding = '10px 12px';
|
||||||
|
btn.style.borderRadius = '8px';
|
||||||
|
btn.style.border = '0';
|
||||||
|
btn.style.background = 'linear-gradient(90deg,#ff7a59,#ffb199)';
|
||||||
|
btn.style.color = '#fff';
|
||||||
|
btn.style.cursor = 'pointer';
|
||||||
|
btn.onclick = ()=>{
|
||||||
|
try{
|
||||||
|
if(socket && socket.connected){
|
||||||
|
socket.emit('room:leave', { roomId }, (resp)=>{ try{ window.location.href = '/'; }catch(_){ window.location.reload(); } });
|
||||||
|
} else { window.location.href = '/'; }
|
||||||
|
}catch(e){ try{ window.location.href = '/'; }catch(_){ window.location.reload(); } }
|
||||||
|
};
|
||||||
|
document.body.appendChild(btn);
|
||||||
|
}catch(e){ console.warn('createSpectatorQuitButton error', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSpectatorQuitButton(){
|
||||||
|
try{ const existing = document.getElementById('spectatorQuitBtn'); if(existing) existing.remove(); isSpectating = false; }catch(_){ }
|
||||||
|
}
|
||||||
|
|
||||||
// movement UI removed: legalMoves and move markers are disabled
|
// movement UI removed: legalMoves and move markers are disabled
|
||||||
|
|
||||||
// selection helpers (client-side)
|
// selection helpers (client-side)
|
||||||
|
|
|
||||||
108
server.js
108
server.js
|
|
@ -178,6 +178,32 @@ function endTurnAfterCard(room, senderId){
|
||||||
}catch(e){ console.error('endTurnAfterCard error', e); }
|
}catch(e){ console.error('endTurnAfterCard error', e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if a king is missing and end the game. Returns an object describing the outcome or null if game continues.
|
||||||
|
function checkAndHandleVictory(room){
|
||||||
|
try{
|
||||||
|
if(!room || !room.boardState) return null;
|
||||||
|
// if the room is already finished, don't re-emit or change state
|
||||||
|
if(room.status === 'finished') return null;
|
||||||
|
const pieces = room.boardState.pieces || [];
|
||||||
|
const hasWhiteKing = pieces.some(p => p && p.type && String(p.type).toUpperCase() === 'K' && p.color === 'w');
|
||||||
|
const hasBlackKing = pieces.some(p => p && p.type && String(p.type).toUpperCase() === 'K' && p.color === 'b');
|
||||||
|
if(hasWhiteKing && hasBlackKing) return null;
|
||||||
|
// both missing -> draw
|
||||||
|
if(!hasWhiteKing && !hasBlackKing){
|
||||||
|
room.status = 'finished';
|
||||||
|
try{ io.to(room.id).emit('game:over', { roomId: room.id, draw: true, boardState: room.boardState }); }catch(_){ }
|
||||||
|
return { over: true, draw: true };
|
||||||
|
}
|
||||||
|
const winnerColor = hasWhiteKing ? 'w' : 'b';
|
||||||
|
const loserColor = hasWhiteKing ? 'b' : 'w';
|
||||||
|
const winner = (room.players || []).find(p => (p.color && p.color[0]) === winnerColor) || null;
|
||||||
|
const loser = (room.players || []).find(p => (p.color && p.color[0]) === loserColor) || null;
|
||||||
|
room.status = 'finished';
|
||||||
|
try{ io.to(room.id).emit('game:over', { roomId: room.id, winnerId: winner && winner.id, loserId: loser && loser.id, winnerColor, boardState: room.boardState }); }catch(_){ }
|
||||||
|
return { over: true, winnerId: winner && winner.id, loserId: loser && loser.id, winnerColor };
|
||||||
|
}catch(e){ console.error('checkAndHandleVictory error', e); return null; }
|
||||||
|
}
|
||||||
|
|
||||||
// Build a default deck from README list. Each card has a unique id, cardId (slug), title and description.
|
// Build a default deck from README list. Each card has a unique id, cardId (slug), title and description.
|
||||||
function buildDefaultDeck(){
|
function buildDefaultDeck(){
|
||||||
const cards = [
|
const cards = [
|
||||||
|
|
@ -749,6 +775,31 @@ io.on('connection', (socket) => {
|
||||||
cb && cb({ ok: true, color, roomId, playerId: assignedId, hostId: room.hostId });
|
cb && cb({ ok: true, color, roomId, playerId: assignedId, hostId: room.hostId });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Allow a client to voluntarily leave a room (immediate removal). This differs from
|
||||||
|
// disconnect which waits briefly to allow quick reconnects.
|
||||||
|
socket.on('room:leave', ({ roomId }, cb) => {
|
||||||
|
try{
|
||||||
|
const room = rooms.get(roomId || socket.data.roomId);
|
||||||
|
const playerId = socket.data.playerId;
|
||||||
|
if(!room) return cb && cb({ error: 'room not found' });
|
||||||
|
if(!playerId) return cb && cb({ error: 'not joined' });
|
||||||
|
// remove player immediately
|
||||||
|
room.players = (room.players || []).filter(p => p.id !== playerId);
|
||||||
|
// if the host left, promote the next player
|
||||||
|
if(room.hostId && !room.players.find(p => p.id === room.hostId)){
|
||||||
|
room.hostId = room.players[0] ? room.players[0].id : null;
|
||||||
|
}
|
||||||
|
// if fewer than 2 players remain, mark the room waiting
|
||||||
|
if((room.players || []).length < 2 && room.status === 'playing') room.status = 'waiting';
|
||||||
|
// leave socket.io room and clear socket data
|
||||||
|
try{ socket.leave(room.id); }catch(_){ }
|
||||||
|
try{ socket.data.roomId = null; }catch(_){ }
|
||||||
|
// notify remaining players
|
||||||
|
try{ sendRoomUpdate(room); }catch(_){ }
|
||||||
|
return cb && cb({ ok: true });
|
||||||
|
}catch(e){ console.error('room:leave error', e); return cb && cb({ error: 'server_error' }); }
|
||||||
|
});
|
||||||
|
|
||||||
// Host can toggle automatic drawing in the waiting room. Only the host may change this setting.
|
// Host can toggle automatic drawing in the waiting room. Only the host may change this setting.
|
||||||
socket.on('room:auto_draw:set', ({ roomId, enabled }, cb) => {
|
socket.on('room:auto_draw:set', ({ roomId, enabled }, cb) => {
|
||||||
const room = rooms.get(roomId);
|
const room = rooms.get(roomId);
|
||||||
|
|
@ -766,6 +817,8 @@ io.on('connection', (socket) => {
|
||||||
socket.on('game:move', ({ roomId, from, to, promotion }, cb) => {
|
socket.on('game:move', ({ roomId, from, to, promotion }, cb) => {
|
||||||
const room = rooms.get(roomId);
|
const room = rooms.get(roomId);
|
||||||
if (!room) return cb && cb({ error: 'room not found' });
|
if (!room) return cb && cb({ error: 'room not found' });
|
||||||
|
// don't allow moves in finished games
|
||||||
|
if(room.status === 'finished') return cb && cb({ error: 'game_over' });
|
||||||
// Basic move handling: validate turn, validate piece ownership, validate target is one of computeLegalMoves,
|
// Basic move handling: validate turn, validate piece ownership, validate target is one of computeLegalMoves,
|
||||||
// apply the move to room.boardState, handle captures, flip turn and broadcast the updated board.
|
// apply the move to room.boardState, handle captures, flip turn and broadcast the updated board.
|
||||||
try{
|
try{
|
||||||
|
|
@ -944,6 +997,18 @@ io.on('connection', (socket) => {
|
||||||
// advance board version
|
// advance board version
|
||||||
board.version = (board.version || 0) + 1;
|
board.version = (board.version || 0) + 1;
|
||||||
|
|
||||||
|
// Check victory (king capture) before further turn bookkeeping. If game ended, broadcast move and game:over and return.
|
||||||
|
try{
|
||||||
|
const end = checkAndHandleVictory(room);
|
||||||
|
if(end && end.over){
|
||||||
|
const moved = { playerId: senderId, from, to };
|
||||||
|
try{ io.to(roomId).emit('move:moved', moved); }catch(_){ }
|
||||||
|
// send personalized updates (room:update) so clients can refresh final board
|
||||||
|
try{ sendRoomUpdate(room); }catch(_){ }
|
||||||
|
return cb && cb({ ok: true, moved, gameOver: end });
|
||||||
|
}
|
||||||
|
}catch(_){ }
|
||||||
|
|
||||||
// If the player was granted a free move by playing a card just now (room._freeMoveFor), consume it
|
// If the player was granted a free move by playing a card just now (room._freeMoveFor), consume it
|
||||||
// and mark that we should end the turn after this move. We DO NOT treat it like a 'double_move' —
|
// and mark that we should end the turn after this move. We DO NOT treat it like a 'double_move' —
|
||||||
// instead it is the player's one move for the turn and should cause normal end-of-turn bookkeeping.
|
// instead it is the player's one move for the turn and should cause normal end-of-turn bookkeeping.
|
||||||
|
|
@ -1183,6 +1248,7 @@ io.on('connection', (socket) => {
|
||||||
socket.on('player:draw', ({ roomId }, cb) => {
|
socket.on('player:draw', ({ roomId }, cb) => {
|
||||||
const room = rooms.get(roomId);
|
const room = rooms.get(roomId);
|
||||||
if(!room) return cb && cb({ error: 'room not found' });
|
if(!room) return cb && cb({ error: 'room not found' });
|
||||||
|
if(room.status === 'finished') return cb && cb({ error: 'game_over' });
|
||||||
const senderId = socket.data.playerId;
|
const senderId = socket.data.playerId;
|
||||||
if(!senderId) return cb && cb({ error: 'not joined' });
|
if(!senderId) return cb && cb({ error: 'not joined' });
|
||||||
const board = room.boardState;
|
const board = room.boardState;
|
||||||
|
|
@ -1271,6 +1337,7 @@ io.on('connection', (socket) => {
|
||||||
socket.on('card:play', ({ roomId, playerId, cardId, payload }, cb) => {
|
socket.on('card:play', ({ roomId, playerId, cardId, payload }, cb) => {
|
||||||
const room = rooms.get(roomId);
|
const room = rooms.get(roomId);
|
||||||
if(!room) return cb && cb({ error: 'room not found' });
|
if(!room) return cb && cb({ error: 'room not found' });
|
||||||
|
if(room.status === 'finished') return cb && cb({ error: 'game_over' });
|
||||||
// enforce sender identity from socket (don't trust client-supplied playerId)
|
// enforce sender identity from socket (don't trust client-supplied playerId)
|
||||||
const senderId = socket.data.playerId;
|
const senderId = socket.data.playerId;
|
||||||
if(!senderId) return cb && cb({ error: 'not joined' });
|
if(!senderId) return cb && cb({ error: 'not joined' });
|
||||||
|
|
@ -1807,14 +1874,21 @@ io.on('connection', (socket) => {
|
||||||
// reuse the mine detonation animation on clients for kamikaz: show the same explosion visual
|
// reuse the mine detonation animation on clients for kamikaz: show the same explosion visual
|
||||||
try{ io.to(room.id).emit('mine:detonated', { roomId: room.id, square: target }); }catch(_){ }
|
try{ io.to(room.id).emit('mine:detonated', { roomId: room.id, square: target }); }catch(_){ }
|
||||||
played.payload = Object.assign({}, payload, { applied: 'kamikaz', appliedTo: target, affected: affected, removedCount: removedPieces.length });
|
played.payload = Object.assign({}, payload, { applied: 'kamikaz', appliedTo: target, affected: affected, removedCount: removedPieces.length });
|
||||||
// After playing kamikaz the player immediately loses their turn (same behavior as steal-piece)
|
// After applying kamikaz, check for victory (kings may have been removed).
|
||||||
try{
|
try{
|
||||||
if(board){
|
const end = checkAndHandleVictory(room);
|
||||||
board.turn = (board.turn === 'w') ? 'b' : 'w';
|
if(end && end.over){
|
||||||
// draw for the next player at the start of their turn
|
// if game over, broadcast final state and stop further turn changes
|
||||||
const nextColor = board.turn;
|
try{ sendRoomUpdate(room); }catch(_){ }
|
||||||
const nextPlayer = (room.players || []).find(p => (p.color && p.color[0]) === nextColor);
|
} else {
|
||||||
if(nextPlayer){ try{ maybeDrawAtTurnStart(room, nextPlayer.id); }catch(_){ } }
|
// normal behavior: the player immediately loses their turn
|
||||||
|
if(board){
|
||||||
|
board.turn = (board.turn === 'w') ? 'b' : 'w';
|
||||||
|
// draw for the next player at the start of their turn
|
||||||
|
const nextColor = board.turn;
|
||||||
|
const nextPlayer = (room.players || []).find(p => (p.color && p.color[0]) === nextColor);
|
||||||
|
if(nextPlayer){ try{ maybeDrawAtTurnStart(room, nextPlayer.id); }catch(_){ } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}catch(_){ }
|
}catch(_){ }
|
||||||
}catch(e){ console.error('kamikaz effect error', e); }
|
}catch(e){ console.error('kamikaz effect error', e); }
|
||||||
|
|
@ -2283,3 +2357,23 @@ const PORT = process.env.PORT || 3000;
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`ChessNut server listening on port ${PORT}`);
|
console.log(`ChessNut server listening on port ${PORT}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Periodic sweep to ensure victory conditions are detected even if some code path
|
||||||
|
// didn't call checkAndHandleVictory after mutating the board. This protects against
|
||||||
|
// missed edge-cases (cards, effects or unexpected flows) by validating rooms once
|
||||||
|
// per second and emitting `game:over` when appropriate.
|
||||||
|
setInterval(() => {
|
||||||
|
try{
|
||||||
|
for(const [id, room] of rooms.entries()){
|
||||||
|
try{
|
||||||
|
if(!room) continue;
|
||||||
|
if(room.status === 'finished') continue;
|
||||||
|
// run the victory check (it will emit game:over and set room.status when needed)
|
||||||
|
const res = checkAndHandleVictory(room);
|
||||||
|
if(res && res.over){
|
||||||
|
try{ sendRoomUpdate(room); }catch(_){ }
|
||||||
|
}
|
||||||
|
}catch(e){ console.error('periodic room check error for', id, e); }
|
||||||
|
}
|
||||||
|
}catch(e){ console.error('periodic victory sweep error', e); }
|
||||||
|
}, 1000);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue