diff --git a/assets/img/bomb.jpg b/assets/img/bomb.jpg new file mode 100644 index 0000000..12770c6 Binary files /dev/null and b/assets/img/bomb.jpg differ diff --git a/public/game.html b/public/game.html index 61eda5b..7dbaf94 100644 --- a/public/game.html +++ b/public/game.html @@ -53,6 +53,8 @@ const remoteMoves = {}; // current boardState cached locally let currentBoardState = null; + let myMines = []; + let pendingCard = null; // when set, next square click is used as card target // safe logger: prefer on-page log if present, otherwise console function log(...args){ @@ -66,6 +68,18 @@ } } + // helper: decide if a card requires a target selected after clicking 'Jouer' + function cardRequiresTarget(cardId){ + if(!cardId) return null; + const id = String(cardId).toLowerCase(); + if(id.indexOf('mine') !== -1) return 'empty'; + if(id.indexOf('rebond') !== -1) return 'owned'; + if(id.indexOf('adoub') !== -1) return 'owned'; + if(id.indexOf('folie') !== -1 || id.indexOf('fou') !== -1) return 'owned'; + if(id.indexOf('fortif') !== -1 || id.indexOf('fortification') !== -1) return 'owned'; + return null; + } + if(!roomId){ alert('roomId manquant dans l\'URL'); } function connectAndJoin(){ @@ -95,6 +109,10 @@ renderBoardFromState(data.boardState); updateTurnBanner(data.boardState); } + // store mines owned by this client (private) + try{ myMines = data.minesOwn || []; }catch(e){ myMines = []; } + // after rendering the board, render mines for owner + try{ renderMines(); }catch(e){ } // render hands: prefer new private `handsOwn` + `handCounts`. // If server (or older code) mistakenly sends a full `hands` object, only show the current player's own hand and public counts — never reveal other players' cards. if(data.hands){ @@ -353,6 +371,28 @@ const square = sqEl.getAttribute('data-square'); if(!square) return; + // If we're in a pending card-targeting mode, handle target selection and do NOT attempt moves + if(pendingCard){ + const req = pendingCard.requireType; // 'empty' or 'owned' + if(req === 'empty'){ + const occupied = currentBoardState && Array.isArray(currentBoardState.pieces) && currentBoardState.pieces.find(p => p.square === square); + if(occupied){ log('square not empty for mine placement'); return; } + } else if(req === 'owned'){ + const piece = currentBoardState && Array.isArray(currentBoardState.pieces) && currentBoardState.pieces.find(p => p.square === square); + const myShort = (myColor && myColor[0]) || ''; + if(!piece || piece.color !== myShort){ log('select one of your pieces'); return; } + } + // send card:play with targetSquare + socket.emit('card:play', { roomId, playerId: myPlayerId, cardId: pendingCard.cardId, payload: { targetSquare: square } }, (resp)=>{ + if(resp && resp.error){ log('card:play error', resp); alert(resp.error); } + else { log('card played with target', resp); } + }); + // clear pending state and highlights + pendingCard = null; + clearTargetHighlights(); + return; + } + // If this square contains a move-marker (either clicked marker or square area), attempt the move. // Prefer the actual clicked marker if present, otherwise look for a marker element inside the square. const clickedMarker = ev.target.closest && ev.target.closest('.move-marker'); @@ -381,8 +421,12 @@ return; } - // default: clicked empty square -> clear selection - clearSelection(); + // default: clicked empty square -> select it (toggle) + if(selectedSquare === square){ + clearSelection(); + } else { + setSelection(square); + } }); } @@ -428,6 +472,89 @@ Object.entries(remoteMoves).forEach(([pid, moves]) => { if(Array.isArray(moves) && moves.length) showRemoteLegalMoves(pid, moves); }); + // render any mines owned by this player + try{ renderMines(); }catch(e){ } + } + + // draw mine icons on squares for mines owned by this client + function renderMines(){ + if(!boardEl) return; + // remove old mine markers + // remove old mine markers (either image or dot) + const prev = boardEl.querySelectorAll('.mine-dot, .mine-img'); + prev.forEach(p=>p.remove()); + (myMines || []).forEach(sq => { + const el = boardEl.querySelector(`.square[data-square="${sq}"]`); + if(!el) return; + // create an image element so the mine has a transparent background and crisp edges + const img = document.createElement('img'); + img.className = 'mine-img'; + // use a project asset (SVG) that has a transparent background + img.src = '/assets/img/bomb.jpg'; + img.draggable = false; + // ensure container is positioned so absolute centering works + if(!el.style.position) el.style.position = 'relative'; + el.appendChild(img); + }); + } + + // target highlighting helpers + function clearTargetHighlights(){ + if(!boardEl) return; + const prev = boardEl.querySelectorAll('.square.targetable'); + prev.forEach(p=>p.classList.remove('targetable')); + const note = document.getElementById('pendingNote'); if(note) note.remove(); + } + function highlightTargets(type){ + if(!boardEl || !currentBoardState) return; + clearTargetHighlights(); + const myShort = (myColor && myColor[0]) || ''; + Array.from(boardEl.querySelectorAll('.square')).forEach(sqEl => { + const sq = sqEl.getAttribute('data-square'); + if(!sq) return; + if(type === 'empty'){ + const occ = (currentBoardState.pieces||[]).find(p=>p.square===sq); + if(!occ) sqEl.classList.add('targetable'); + } else if(type === 'owned'){ + const occ = (currentBoardState.pieces||[]).find(p=>p.square===sq); + if(occ && occ.color === myShort) sqEl.classList.add('targetable'); + } + }); + // add a small note near hands + try{ + const handsEl = document.getElementById('hands'); + if(handsEl){ + const note = document.createElement('div'); + note.id = 'pendingNote'; + note.style.marginTop = '8px'; + note.style.display = 'flex'; + note.style.alignItems = 'center'; + note.style.gap = '8px'; + const txt = document.createElement('span'); + txt.textContent = 'Sélectionnez la case cible pour la carte...'; + txt.style.fontWeight = '700'; + note.appendChild(txt); + const cancelBtn = document.createElement('button'); + cancelBtn.type = 'button'; + cancelBtn.id = 'pendingCancelBtn'; + cancelBtn.textContent = 'Annuler'; + // use a dedicated class to ensure good contrast against the hands background + cancelBtn.className = 'btn-cancel'; + cancelBtn.addEventListener('click', (ev)=>{ ev.stopPropagation(); cancelPendingCard(); }); + note.appendChild(cancelBtn); + handsEl.prepend(note); + } + }catch(e){} + } + + // cancel a pending card selection (client-only) + function cancelPendingCard(){ + if(!pendingCard) return; + pendingCard = null; + clearTargetHighlights(); + log('Pending card selection annulée'); + // remove pendingNote if present + const note = document.getElementById('pendingNote'); if(note) note.remove(); } // build grid with independent square elements and data-square attributes @@ -503,7 +630,17 @@ function emitPlay(){ if(!socket) return; const cid = cardEl.dataset.cardId; - socket.emit('card:play', { roomId, playerId: myPlayerId, cardId: cid, payload: {} }, (resp)=>{ + const requireType = cardRequiresTarget(cid); + if(requireType){ + // enter pending target mode: next square click will send the card with payload.targetSquare + pendingCard = { cardId: cid, cardUuid: cardEl.dataset.cardUuid || '', requireType }; + highlightTargets(requireType); + log('Pending card selection for', cid, '-> select target'); + return; + } + // immediate-play card (no target required) + const payload = {}; + socket.emit('card:play', { roomId, playerId: myPlayerId, cardId: cid, payload }, (resp)=>{ if(resp && resp.error){ log('card:play error', resp); alert(resp.error); } else { log('card played', resp); } }); diff --git a/public/styles/style.css b/public/styles/style.css index 364ed12..b5063cc 100644 --- a/public/styles/style.css +++ b/public/styles/style.css @@ -63,6 +63,36 @@ a:hover{text-decoration:underline} } .square .move-marker:hover{ transform:translate(-50%,-50%) scale(1.12); opacity:0.95 } +/* square highlight when selecting a target for a card: a small hollow ring strictly inside the square */ +.square.targetable{ outline: none; box-shadow: none; } +.square.targetable::after{ + content: ''; + position: absolute; + left: 50%; top: 50%; transform: translate(-50%, -50%); + width: 36%; height: 36%; border-radius: 50%; + border: 2px solid rgba(234,88,12,0.95); + box-shadow: 0 2px 6px rgba(234,88,12,0.12) inset; + pointer-events: none; z-index: 3; +} + +/* small circular mine marker strictly inside the square */ +.square .mine-dot{ + position:absolute; + left:50%; top:50%; transform:translate(-50%,-50%); + width:36%; height:36%; border-radius:50%; + background: radial-gradient(circle at 30% 30%, #ffdfe0 0%, #ff6b6b 30%, #b91c1c 100%); + box-shadow: inset 0 2px 6px rgba(255,255,255,0.25), 0 2px 6px rgba(0,0,0,0.25); + pointer-events:none; z-index:2; +} + +/* image-based mine: prefer SVG with transparent background for crisp edges */ +.square .mine-img{ + position:absolute; + left:50%; top:50%; transform:translate(-50%,-50%); + width:40%; height:auto; max-height:42%; + pointer-events:none; z-index:2; image-rendering: -webkit-optimize-contrast; +} + /* highlighted/selected square when a player selects a piece */ .square.selected{ outline: 3px solid rgba(245,158,11,0.95); /* amber */ @@ -112,6 +142,18 @@ a:hover{text-decoration:underline} .btn-ghost{background:transparent;border:1px solid rgba(11,18,32,0.06);padding:8px 10px;border-radius:8px;color:inherit} .btn-cta{background:linear-gradient(90deg,#3b82f6,#60a5fa);box-shadow:0 8px 20px rgba(59,130,246,0.18)} +/* cancel button shown during pending card selection: white background, red text for clear contrast */ +.btn-cancel{ + background: #ffffff; + color: #b91c1c; /* red */ + border: 1px solid rgba(185,28,28,0.14); + padding: 6px 8px; + border-radius: 6px; + cursor: pointer; + font-weight: 700; +} +.btn-cancel:hover{ background: #fff5f5; } + /* floating small piece icons */ .floating-piece{position:absolute;opacity:0.06;width:160px;height:160px;pointer-events:none;transform:translate(-10%,-10%);} diff --git a/server.js b/server.js index f5a7855..43d5bc9 100644 --- a/server.js +++ b/server.js @@ -43,6 +43,11 @@ function sendRoomUpdate(room){ // attach visible squares for this recipient (fog of war) try{ payload.visibleSquares = Array.from(visibleSquaresForPlayer(room, p.id) || []); + // attach any mines owned by this recipient (mines are hidden from other players) + try{ + const mines = (room.activeCardEffects || []).filter(e => e.type === 'mine' && e.playerId === p.id).map(e => e.square); + payload.minesOwn = mines; + }catch(_){ payload.minesOwn = []; } }catch(e){ payload.visibleSquares = []; } if(p.socketId){ io.to(p.socketId).emit('room:update', payload); @@ -101,7 +106,7 @@ function buildDefaultDeck(){ // ['trou de ver','Deux cases du plateau deviennent maintenant la même'], ['jouer deux fois','Le joueur peut déplacer deux pièces'], // ['annulation d une carte','Annule l effet d une carte qui est jouée par l adversaire'], - // ['placement de mines','Le joueur place une mine sur une case vide sans la révéler au joueur adverse. Une pièce qui se pose dessus explose et est capturée par le joueur ayant placé la mine'], + ['placement de mines','Le joueur place une mine sur une case vide sans la révéler au joueur adverse. Une pièce qui se pose dessus explose et est capturée par le joueur ayant placé la mine'], // ['vole d une pièce','Désigne une pièce non roi qui change de camp'], // ['promotion','Un pion au choix est promu reine'], // ['vole d une carte','Vole une carte aléatoirement au joueur adverse'], @@ -687,6 +692,32 @@ io.on('connection', (socket) => { } }catch(e){ console.error('consuming card effects error', e); } + // Check for hidden mines at the destination square. If a mine belonging to another player + // is present, detonate it: remove the moving piece and consume the mine. The mine location + // is kept private (only the owner sees it in their `minesOwn`), but detonations are public. + try{ + room.activeCardEffects = room.activeCardEffects || []; + for(let i = room.activeCardEffects.length - 1; i >= 0; i--){ + const e = room.activeCardEffects[i]; + if(e && e.type === 'mine' && e.square === to){ + // owner is immune to their own mine (assumption). Only detonate for other players. + if(e.playerId !== senderId){ + // remove the moving piece from the board + const rmIdx = pieces.findIndex(p => p.id === moving.id); + if(rmIdx >= 0){ pieces.splice(rmIdx, 1); } + // consume the mine + try{ room.activeCardEffects.splice(i,1); }catch(_){ } + // broadcast detonation to the room (informational) + try{ io.to(roomId).emit('mine:detonated', { roomId: room.id, ownerId: e.playerId, detonatorId: senderId, square: to, piece: moving }); }catch(_){ } + // privately inform the owner as well with effect id and piece details + try{ const owner = (room.players||[]).find(p => p.id === e.playerId); if(owner && owner.socketId) io.to(owner.socketId).emit('mine:detonated:private', { roomId: room.id, effectId: e.id, square: to, piece: moving }); }catch(_){ } + } + // a mine was found/handled; break (only one mine per square expected) + break; + } + } + }catch(err){ console.error('mine detonation error', err); } + // advance board version board.version = (board.version || 0) + 1; @@ -1127,6 +1158,36 @@ io.on('connection', (socket) => { played.payload = Object.assign({}, payload, { applied: 'anneau' }); }catch(e){ console.error('anneau effect error', e); } } + // placement de mines: place a hidden mine on an empty square (hidden from other players) + else if((typeof cardId === 'string' && cardId.indexOf('mine') !== -1)){ + try{ + const board = room.boardState; + let target = payload && payload.targetSquare; + if(!target){ try{ target = socket.data && socket.data.lastSelectedSquare; }catch(e){ target = null; } } + // validate board and empty target + const targetOccupied = (board && board.pieces || []).find(p => p.square === target); + if(!board || !target || targetOccupied){ + // Invalid target: the card is still consumed (player loses the card as requested). + // We do not restore the removed card to the player's hand. Record in played payload that the placement failed. + played.payload = Object.assign({}, payload, { applied: 'mine_failed', attemptedTo: target }); + // Notify only the owner that the card was used but the mine was not placed + try{ const owner = (room.players || []).find(p => p.id === senderId); if(owner && owner.socketId) io.to(owner.socketId).emit('card:effect:applied', { roomId: room.id, effect: { id: played.id, type: 'mine_failed', playerId: senderId, square: target, ts: Date.now() } }); }catch(_){ } + // continue without creating a mine + } else { + // record mine effect scoped to the player; do NOT broadcast location to other players + room.activeCardEffects = room.activeCardEffects || []; + const effect = { id: played.id, type: 'mine', playerId: senderId, square: target, ts: Date.now() }; + room.activeCardEffects.push(effect); + // notify only the owner about the mine placement (keep it hidden from opponents) + try{ + const owner = (room.players || []).find(p => p.id === senderId); + if(owner && owner.socketId) io.to(owner.socketId).emit('card:effect:applied', { roomId: room.id, effect }); + }catch(_){ } + // for the public played record we mark the card as used without revealing the square + played.payload = Object.assign({}, payload, { applied: 'mine' }); + } + }catch(e){ console.error('mine placement error', e); } + } // jouer deux fois: grant the playing player one extra move this turn (does not allow another card play) else if(cardId === 'jouer_deux_fois' || (typeof cardId === 'string' && cardId.indexOf('jouer') !== -1 && cardId.indexOf('deux') !== -1)){ try{