936 lines
45 KiB
HTML
936 lines
45 KiB
HTML
<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>ChessNut - Partie</title>
|
|
<link rel="stylesheet" href="/styles/style.css">
|
|
</head>
|
|
<body>
|
|
<h2>ChessNut — Partie</h2>
|
|
<div id="turnBanner" style="margin-bottom:8px"></div>
|
|
<div>Room: <strong id="roomId">-</strong> • Vous jouez: <strong id="myColor">-</strong>
|
|
<div class="layout-row">
|
|
<div>
|
|
<div id="board"></div>
|
|
</div>
|
|
<aside style="min-width:320px;margin-left:18px">
|
|
|
|
<section class="card" style="margin-top:12px">
|
|
<div><strong>Outils / Logs</strong></div>
|
|
<div style="margin-top:8px">
|
|
<!-- movement controls removed as requested -->
|
|
</div>
|
|
<pre id="log" style="margin-top:8px;height:160px;overflow:auto">logs...</pre>
|
|
</section>
|
|
<div id="hands" style="margin-top:8px;display:flex;flex-direction:column;gap:8px"></div>
|
|
</aside>
|
|
</div>
|
|
|
|
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
|
|
<script>
|
|
function qs(name){ const url = new URL(window.location.href); return url.searchParams.get(name); }
|
|
const roomId = qs('roomId');
|
|
const playerId = qs('playerId') || undefined;
|
|
const roomIdEl = document.getElementById('roomId');
|
|
const playerIdEl = document.getElementById('playerId');
|
|
if(roomIdEl) roomIdEl.textContent = roomId || '-';
|
|
if(playerIdEl) playerIdEl.textContent = playerId || '-';
|
|
|
|
// optional UI elements: support removing the sidebar/sections without breaking JS
|
|
const logEl = document.getElementById('log');
|
|
const playersEl = document.getElementById('players');
|
|
const boardEl = document.getElementById('board');
|
|
const myColorEl = document.getElementById('myColor');
|
|
let socket = null;
|
|
let myColor = null;
|
|
// selection state for this client
|
|
let selectedSquare = null; // algebraic name like 'e2'
|
|
let selectedSquareEl = null;
|
|
let myPlayerId = playerId || null;
|
|
// track remote selections: { playerId: square }
|
|
const remoteSelections = {};
|
|
// track remote moves: { playerId: [moves] }
|
|
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){
|
|
if(logEl){
|
|
try{
|
|
logEl.textContent += '\n' + args.map(a=>typeof a==='object'?JSON.stringify(a):a).join(' ');
|
|
logEl.scrollTop = logEl.scrollHeight;
|
|
}catch(e){ console.log(...args); }
|
|
} else {
|
|
console.log(...args);
|
|
}
|
|
}
|
|
|
|
// 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';
|
|
// kamikaz targets one of your pieces (play card, then choose a piece)
|
|
if(id.indexOf('kamikaz') !== -1) return 'owned';
|
|
if(id.indexOf('invis') !== -1 || id.indexOf('invisible') !== -1) return 'owned';
|
|
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';
|
|
// promotion targets one of your pawns (special handling client-side to show promotion choice)
|
|
if(id.indexOf('promotion') !== -1 || id.indexOf('promot') !== -1 || id.indexOf('promouvoir') !== -1) return 'owned_pawn';
|
|
// steal a card (target a player) if the card mentions 'carte'/'card' — otherwise a 'vol' often targets a piece
|
|
if(id.indexOf('vol') !== -1 || id.indexOf('vole') !== -1 || id.indexOf('steal') !== -1){
|
|
if(id.indexOf('carte') !== -1 || id.indexOf('card') !== -1) return 'player';
|
|
return 'enemy';
|
|
}
|
|
if(id.indexOf('coin') !== -1 || id === 'coin_coin') return 'owned';
|
|
return null;
|
|
}
|
|
|
|
if(!roomId){ alert('roomId manquant dans l\'URL'); }
|
|
|
|
function connectAndJoin(){
|
|
socket = io();
|
|
socket.on('connect', ()=>{ log('socket connecté', socket.id); });
|
|
|
|
socket.on('room:update', (data)=>{
|
|
log('room:update', data);
|
|
// server now sends `boardState` (JSON). renderBoardFromState will handle it.
|
|
if(playersEl){
|
|
playersEl.innerHTML = '';
|
|
(data.players||[]).forEach(p=>{
|
|
const li = document.createElement('li');
|
|
li.textContent = p.id + ' (' + p.color + ')';
|
|
playersEl.appendChild(li);
|
|
});
|
|
}
|
|
if(data.size){
|
|
// ensure grid layout matches requested size and build squares
|
|
buildGrid(data.size);
|
|
// adjust board width/height responsively (keep squares roughly proportional)
|
|
const side = Math.min(800, Math.max(320, data.size * 80));
|
|
boardEl.style.width = side + 'px';
|
|
boardEl.style.height = side + 'px';
|
|
}
|
|
if(data.boardState) {
|
|
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){
|
|
// legacy: full hands object mapping playerId -> array
|
|
const own = (data.hands && data.hands[myPlayerId]) ? data.hands[myPlayerId] : [];
|
|
const counts = {};
|
|
Object.entries(data.hands || {}).forEach(([pid, arr])=>{ counts[pid] = (arr && arr.length) || 0; });
|
|
renderHands(own, counts);
|
|
} else if(data.handsOwn || data.handCounts){
|
|
renderHands(data.handsOwn || [], data.handCounts || {});
|
|
}
|
|
});
|
|
socket.on('game:over', (data)=>{ log('game:over', 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
|
|
socket.on('card:drawn', (data) => {
|
|
try{
|
|
log('recv card:drawn', data);
|
|
// show a small inline notification in the hands section
|
|
showCardDrawnNotification(data);
|
|
}catch(e){ console.warn('card:drawn handler error', e); }
|
|
});
|
|
// when a card is played by any player, ensure the UI removes it from the owner's hand
|
|
socket.on('card:played', (data) => {
|
|
try{
|
|
log('recv card:played', data);
|
|
// data.played or data depending on server shape
|
|
const played = data && data.played ? data.played : data;
|
|
const cardObj = played && (played.card || played.cardObj || null);
|
|
const owner = played && played.playerId;
|
|
// if we have the card object and it's our card, remove its DOM element
|
|
if(owner === myPlayerId){
|
|
// find the card element by uuid (data-card-uuid) or by cardId
|
|
const row = document.querySelector('.cards-row');
|
|
if(row){
|
|
const elems = Array.from(row.querySelectorAll('.pokemon-card'));
|
|
elems.forEach(el => {
|
|
const uuid = el.dataset.cardUuid || '';
|
|
const cid = el.dataset.cardId || '';
|
|
if((cardObj && cardObj.id && uuid && uuid === cardObj.id) || (cardObj && cardObj.cardId && cid && cid === cardObj.cardId)){
|
|
el.remove();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}catch(e){ console.warn('card:played handler error', e); }
|
|
});
|
|
// show card effect notifications (both public and private) so owners get feedback on failed placements/promotions
|
|
socket.on('card:effect:applied', (data) => {
|
|
try{
|
|
const effect = data && data.effect ? data.effect : data;
|
|
if(!effect) return;
|
|
// if this is a private or public effect relating to this player, show a small note
|
|
if(effect.playerId && effect.playerId === myPlayerId){
|
|
showEffectNotification(effect);
|
|
} else if(effect.type && (effect.type === 'promotion' || effect.type === 'steal' || effect.type === 'mine')){
|
|
// public notable effects
|
|
showEffectNotification(effect);
|
|
}
|
|
}catch(e){ console.warn('card:effect:applied handler error', e); }
|
|
});
|
|
// when the server sends the stolen card to the stealer (private)
|
|
socket.on('card:stolen', (data) => {
|
|
try{
|
|
log('recv card:stolen', data);
|
|
// show notification and a small highlight in hands
|
|
const handsEl = document.getElementById('hands');
|
|
if(handsEl){
|
|
const note = document.createElement('div'); note.className = 'card-draw-note';
|
|
note.textContent = data && data.card ? `Vous avez volé : ${data.card.title || data.card.cardId || 'une carte'}` : 'Vous avez volé une carte';
|
|
handsEl.prepend(note); setTimeout(()=> note.remove(), 6000);
|
|
}
|
|
}catch(e){ console.warn('card:stolen handler error', e); }
|
|
});
|
|
socket.on('card:lost', (data) => {
|
|
try{ log('recv card:lost', data); showEffectNotification({ type: 'card_lost' }); }catch(e){ }
|
|
});
|
|
// selection events (include moves) broadcasted by server
|
|
socket.on('game:select', (data) => {
|
|
try{
|
|
const pid = data && data.playerId;
|
|
const square = data && data.square;
|
|
const moves = data && data.moves;
|
|
log('recv game:select', { pid, square, moves: Array.isArray(moves) ? moves.length : moves });
|
|
// cleared selection
|
|
if(!square){
|
|
if(pid === myPlayerId){
|
|
// our selection cleared (maybe by reconnection) - clear locally without re-emitting
|
|
clearSelection(false);
|
|
} else {
|
|
if(pid){
|
|
clearRemoteSelection(pid);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if(pid === myPlayerId){
|
|
// re-apply local selection (don't emit) and show markers
|
|
setSelection(square, false);
|
|
if(Array.isArray(moves) && moves.length) showLegalMoves(moves);
|
|
else clearLegalMarkers();
|
|
} else {
|
|
// remote player's selection
|
|
if(pid) setRemoteSelection(pid, square);
|
|
if(Array.isArray(moves) && moves.length){
|
|
remoteMoves[pid] = moves;
|
|
showRemoteLegalMoves(pid, moves);
|
|
} else {
|
|
remoteMoves[pid] = [];
|
|
if(pid) clearRemoteMoveMarkers(pid);
|
|
}
|
|
}
|
|
}catch(e){ console.warn('game:select handler error', e); }
|
|
});
|
|
|
|
socket.on('move:moved', (data) => {
|
|
try{
|
|
log('recv move:moved', data && { from: data.from, to: data.to });
|
|
// clear any local selection (move completed) before re-render
|
|
clearSelection(false);
|
|
if(data && data.boardState) renderBoardFromState(data.boardState);
|
|
if(data && data.boardState) updateTurnBanner(data.boardState);
|
|
}catch(e){ console.warn('move:moved handler error', e); }
|
|
});
|
|
|
|
// mine detonation: show explosion animation at the square and remove local mine visual immediately
|
|
socket.on('mine:detonated', (evt) => {
|
|
try{
|
|
log('recv mine:detonated', evt);
|
|
// evt.square is algebraic name like 'e4'
|
|
if(evt && evt.square) showExplosionAt(evt.square);
|
|
// remove any mine image/dot at that square (owner will get updated minesOwn via room:update)
|
|
try{
|
|
const sqEl = boardEl && boardEl.querySelector && boardEl.querySelector(`.square[data-square="${evt.square}"]`);
|
|
if(sqEl){ const img = sqEl.querySelector('.mine-img'); if(img) img.remove(); const dot = sqEl.querySelector('.mine-dot'); if(dot) dot.remove(); }
|
|
}catch(_){ }
|
|
}catch(e){ console.warn('mine:detonated handler error', e); }
|
|
});
|
|
|
|
// private detonation info for the mine owner (optional handling)
|
|
socket.on('mine:detonated:private', (evt) => {
|
|
try{ log('recv mine:detonated:private', evt); /* could show owner-specific UI later */ }catch(e){}
|
|
});
|
|
|
|
socket.emit('room:join', { roomId, playerId }, (resp)=>{
|
|
if(resp && resp.error){ log('join error', resp); alert(resp.error); return; }
|
|
log('Rejoint room', resp);
|
|
// remember assigned id/color from server
|
|
if(resp.playerId){
|
|
myPlayerId = resp.playerId;
|
|
if(playerIdEl) playerIdEl.textContent = resp.playerId;
|
|
}
|
|
if(resp.color){
|
|
myColor = resp.color;
|
|
if(myColorEl) myColorEl.textContent = myColor;
|
|
// orient board for black
|
|
if(boardEl){
|
|
if(myColor === 'black') boardEl.classList.add('flipped');
|
|
else boardEl.classList.remove('flipped');
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
connectAndJoin();
|
|
|
|
function updateTurnBanner(boardState){
|
|
const banner = document.getElementById('turnBanner');
|
|
if(!banner) return;
|
|
if(!boardState || !boardState.turn || !myColor){
|
|
banner.classList.remove('show');
|
|
return;
|
|
}
|
|
// myColor is 'white' or 'black', boardState.turn is 'w' or 'b'
|
|
const myShort = (myColor && myColor[0]) || '';
|
|
if(myShort === boardState.turn){
|
|
banner.textContent = "C'est à vous de jouer";
|
|
banner.classList.add('show');
|
|
} else {
|
|
banner.textContent = "En attente du coup de l'adversaire";
|
|
banner.classList.add('show');
|
|
}
|
|
}
|
|
|
|
// movement UI removed: legalMoves and move markers are disabled
|
|
|
|
// selection helpers (client-side)
|
|
function clearSelection(emit = true){
|
|
if(selectedSquareEl){
|
|
selectedSquareEl.classList.remove('selected');
|
|
selectedSquareEl.classList.remove('hide-selection');
|
|
}
|
|
selectedSquare = null;
|
|
selectedSquareEl = null;
|
|
const fromEl = document.getElementById('from');
|
|
if(fromEl) fromEl.value = '';
|
|
// notify others that we cleared selection
|
|
if(emit && socket && myPlayerId){
|
|
socket.emit('game:select', { roomId, square: null }, ()=>{});
|
|
}
|
|
// clear any shown legal-move markers
|
|
if(boardEl) clearLegalMarkers();
|
|
}
|
|
|
|
function setSelection(squareName, emit = true){
|
|
if(!squareName) return clearSelection(emit);
|
|
if(selectedSquareEl) selectedSquareEl.classList.remove('selected');
|
|
// clear any existing markers immediately (so selecting a piece with no moves removes old markers)
|
|
clearLegalMarkers();
|
|
const sqEl = boardEl.querySelector(`.square[data-square="${squareName}"]`);
|
|
if(!sqEl){ selectedSquare = null; selectedSquareEl = null; return; }
|
|
selectedSquare = squareName;
|
|
selectedSquareEl = sqEl;
|
|
sqEl.classList.add('selected');
|
|
// hide the square selection outline while keeping the piece
|
|
sqEl.classList.add('hide-selection');
|
|
const fromEl = document.getElementById('from');
|
|
if(fromEl) fromEl.value = squareName;
|
|
// notify others of our selection
|
|
if(emit && socket && myPlayerId){
|
|
socket.emit('game:select', { roomId, square: squareName }, ()=>{});
|
|
}
|
|
// legal moves will be provided by server via the 'game:select' event
|
|
}
|
|
|
|
// show markers for legal moves when clicking any square (any piece color)
|
|
function clearLegalMarkers(){
|
|
if(!boardEl) return;
|
|
const prev = boardEl.querySelectorAll('.move-marker');
|
|
prev.forEach(p=>p.remove());
|
|
}
|
|
|
|
function showLegalMoves(moves){
|
|
clearLegalMarkers();
|
|
(moves||[]).forEach(m => {
|
|
const to = m.to || (m.move && m.move.to);
|
|
if(!to) return;
|
|
const target = boardEl.querySelector(`.square[data-square="${to}"]`);
|
|
if(!target) return;
|
|
const marker = document.createElement('div');
|
|
marker.className = 'move-marker';
|
|
// store metadata if needed later
|
|
marker.dataset.from = m.from || '';
|
|
marker.dataset.to = to;
|
|
target.appendChild(marker);
|
|
});
|
|
}
|
|
|
|
// remote move markers: attach remoteBy and allow clearing per player
|
|
function showRemoteLegalMoves(playerId, moves){
|
|
clearRemoteMoveMarkers(playerId);
|
|
(moves||[]).forEach(m => {
|
|
const to = m.to || (m.move && m.move.to);
|
|
if(!to) return;
|
|
const target = boardEl.querySelector(`.square[data-square="${to}"]`);
|
|
if(!target) return;
|
|
const marker = document.createElement('div');
|
|
marker.className = 'move-marker';
|
|
marker.dataset.from = m.from || '';
|
|
marker.dataset.to = to;
|
|
marker.dataset.remoteBy = playerId;
|
|
target.appendChild(marker);
|
|
});
|
|
}
|
|
|
|
function clearRemoteMoveMarkers(playerId){
|
|
if(!boardEl) return;
|
|
const prev = boardEl.querySelectorAll(`.move-marker[data-remote-by="${playerId}"]`);
|
|
prev.forEach(p=>p.remove());
|
|
}
|
|
|
|
// remote selection helpers: mark a square as selected by another player
|
|
function setRemoteSelection(playerId, squareName){
|
|
// clear previous for that player
|
|
clearRemoteSelection(playerId);
|
|
const sqEl = boardEl.querySelector(`.square[data-square="${squareName}"]`);
|
|
if(!sqEl) return;
|
|
remoteSelections[playerId] = squareName;
|
|
sqEl.classList.add('selected-remote');
|
|
// hide the square selection outline for remote selection as well
|
|
sqEl.classList.add('hide-selection');
|
|
sqEl.dataset.selectedBy = playerId;
|
|
}
|
|
|
|
function clearRemoteSelection(playerId){
|
|
const prev = remoteSelections[playerId];
|
|
if(!prev) return;
|
|
const prevEl = boardEl.querySelector(`.square[data-square="${prev}"]`);
|
|
if(prevEl){
|
|
prevEl.classList.remove('selected-remote');
|
|
delete prevEl.dataset.selectedBy;
|
|
prevEl.classList.remove('hide-selection');
|
|
}
|
|
delete remoteSelections[playerId];
|
|
// also clear remote move markers
|
|
clearRemoteMoveMarkers(playerId);
|
|
delete remoteMoves[playerId];
|
|
}
|
|
|
|
if(boardEl){
|
|
boardEl.addEventListener('click', (ev)=>{
|
|
const sqEl = ev.target.closest && ev.target.closest('.square');
|
|
if(!sqEl) return;
|
|
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' | 'owned' | 'owned_pawn' | 'enemy'
|
|
const myShort = (myColor && myColor[0]) || '';
|
|
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; }
|
|
// send mine placement
|
|
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); }
|
|
});
|
|
pendingCard = null; clearTargetHighlights(); return;
|
|
} else if(req === 'owned'){
|
|
const piece = currentBoardState && Array.isArray(currentBoardState.pieces) && currentBoardState.pieces.find(p => p.square === square);
|
|
if(!piece || piece.color !== myShort){ log('select one of your pieces'); return; }
|
|
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); }
|
|
});
|
|
pendingCard = null; clearTargetHighlights(); return;
|
|
} else if(req === 'owned_pawn'){
|
|
// must be one of your pawns — show promotion choice modal before sending
|
|
const piece = currentBoardState && Array.isArray(currentBoardState.pieces) && currentBoardState.pieces.find(p => p.square === square);
|
|
if(!piece || piece.color !== myShort || !piece.type || piece.type.toLowerCase() !== 'p'){
|
|
log('select one of your pawns for promotion'); return;
|
|
}
|
|
// show promotion UI; when the player picks a piece type, emit the card:play with payload.promotion
|
|
showPromotionModal(square, (promotionChoice)=>{
|
|
if(!promotionChoice) {
|
|
// canceled
|
|
pendingCard = null; clearTargetHighlights(); return;
|
|
}
|
|
socket.emit('card:play', { roomId, playerId: myPlayerId, cardId: pendingCard.cardId, payload: { targetSquare: square, promotion: promotionChoice } }, (resp)=>{
|
|
if(resp && resp.error){ log('card:play error', resp); alert(resp.error); }
|
|
else { log('card played with promotion', resp); }
|
|
});
|
|
pendingCard = null; clearTargetHighlights();
|
|
});
|
|
return;
|
|
} else if(req === 'enemy'){
|
|
const occ = currentBoardState && Array.isArray(currentBoardState.pieces) && currentBoardState.pieces.find(p => p.square === square);
|
|
if(!occ || occ.color === myShort){ log('select an enemy piece'); return; }
|
|
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); }
|
|
});
|
|
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');
|
|
const marker = clickedMarker || sqEl.querySelector('.move-marker');
|
|
if(marker){
|
|
// ignore remote player's markers
|
|
const remoteBy = marker.dataset && marker.dataset.remoteBy;
|
|
if(remoteBy && remoteBy !== myPlayerId){ log('marker belongs to remote player, ignoring'); return; }
|
|
const to = marker.dataset && marker.dataset.to;
|
|
const from = marker.dataset && marker.dataset.from ? marker.dataset.from : selectedSquare;
|
|
if(!to || !from){ log('invalid marker metadata', { from, to }); return; }
|
|
// only allow if it's our turn
|
|
const myShort = (myColor && myColor[0]) || '';
|
|
if(!currentBoardState || currentBoardState.turn !== myShort){ log('not your turn'); return; }
|
|
socket.emit('game:move', { roomId, from, to }, (resp)=>{
|
|
if(resp && resp.error){ log('move error', resp); alert(resp.error); }
|
|
else { log('move sent', resp); }
|
|
});
|
|
return;
|
|
}
|
|
|
|
// clicked a piece -> select it (no legal moves requested)
|
|
const pieceImg = ev.target.closest && ev.target.closest('img.piece');
|
|
if(pieceImg){
|
|
setSelection(square);
|
|
return;
|
|
}
|
|
|
|
// default: clicked empty square -> select it (toggle)
|
|
if(selectedSquare === square){
|
|
clearSelection();
|
|
} else {
|
|
setSelection(square);
|
|
}
|
|
});
|
|
}
|
|
|
|
// move button removed
|
|
|
|
// Board rendering using provided SVG assets and `boardState` JSON.
|
|
const pieceSetPath = '/assets/chess-pieces/chess_maestro_bw';
|
|
// board visual defaults are handled by CSS (background, size, border, position)
|
|
|
|
function renderBoardFromState(boardState){
|
|
if(!boardState) return;
|
|
// cache boardState for client-side checks (turn etc.)
|
|
currentBoardState = boardState;
|
|
// clear existing pieces but keep squares if present
|
|
const squares = boardEl.querySelectorAll('.square');
|
|
squares.forEach(s=> s.innerHTML = '');
|
|
|
|
// place pieces according to boardState.pieces (expects algebraic square names)
|
|
(boardState.pieces || []).forEach(p => {
|
|
const squareName = p.square;
|
|
if(!squareName) return;
|
|
const sqEl = boardEl.querySelector(`.square[data-square="${squareName}"]`);
|
|
if(!sqEl) return;
|
|
const letter = p.type ? p.type.toUpperCase() : '?';
|
|
const filename = (p.color === 'w' ? 'w' + letter : 'b' + letter) + '.svg';
|
|
const img = document.createElement('img');
|
|
img.src = `${pieceSetPath}/${filename}`;
|
|
img.className = 'piece';
|
|
sqEl.appendChild(img);
|
|
});
|
|
|
|
// re-apply selection if the previously selected square still exists
|
|
if(selectedSquare){
|
|
// re-apply local selection without re-emitting
|
|
setSelection(selectedSquare, false);
|
|
}
|
|
// re-apply remote selections
|
|
Object.entries(remoteSelections).forEach(([pid, sq]) => {
|
|
const el = boardEl.querySelector(`.square[data-square="${sq}"]`);
|
|
if(el){ el.classList.add('selected-remote'); el.classList.add('hide-selection'); }
|
|
});
|
|
// re-apply remote move markers
|
|
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.alt = 'mine';
|
|
img.onerror = function(){
|
|
try{ if(this.src && this.src.indexOf('bomb.jpg') === -1) this.src = '/assets/img/bomb.jpg'; }
|
|
catch(e){}
|
|
};
|
|
img.draggable = false;
|
|
// ensure container is positioned so absolute centering works
|
|
if(!el.style.position) el.style.position = 'relative';
|
|
el.appendChild(img);
|
|
});
|
|
}
|
|
|
|
// show an explosion animation centered on a square (client-side visual)
|
|
function showExplosionAt(square){
|
|
if(!boardEl || !square) return;
|
|
const sqEl = boardEl.querySelector(`.square[data-square="${square}"]`);
|
|
if(!sqEl) return;
|
|
// ensure container is positioned so absolute centering works
|
|
if(!sqEl.style.position) sqEl.style.position = 'relative';
|
|
|
|
// Try to play an authored explosion video if available; fallback to CSS explosion if not.
|
|
const videoPath = '/assets/media/FireElement01.mov';
|
|
const vid = document.createElement('video');
|
|
vid.className = 'explosion-video';
|
|
vid.src = videoPath;
|
|
vid.autoplay = true; vid.muted = true; vid.playsInline = true; vid.loop = false; vid.preload = 'auto';
|
|
vid.style.pointerEvents = 'none';
|
|
|
|
let usedVideo = false;
|
|
// Attach event listeners to handle success or failure
|
|
const cleanup = ()=>{ try{ if(vid && vid.parentNode) vid.parentNode.removeChild(vid); }catch(_){ } };
|
|
vid.addEventListener('canplay', ()=>{
|
|
// video is available and can play — use it
|
|
try{ sqEl.appendChild(vid); usedVideo = true; vid.play().catch(()=>{}); }
|
|
catch(_){ usedVideo = false; }
|
|
}, { once: true });
|
|
// if video errors (file missing or codec issue), fallback to CSS explosion
|
|
vid.addEventListener('error', ()=>{
|
|
try{ cleanup(); }catch(_){ }
|
|
// fallback animation
|
|
const ex = document.createElement('div'); ex.className = 'explosion'; sqEl.appendChild(ex);
|
|
requestAnimationFrame(()=> ex.classList.add('play'));
|
|
setTimeout(()=>{ try{ ex.remove(); }catch(_){ if(ex && ex.parentNode) ex.parentNode.removeChild(ex); } }, 700);
|
|
}, { once: true });
|
|
// If video ends, remove it
|
|
vid.addEventListener('ended', ()=>{ try{ cleanup(); }catch(_){ } }, { once: true });
|
|
// start loading — if canplay won't fire within a short time, fallback to CSS
|
|
vid.load();
|
|
// fallback timeout: if video hasn't started in 180ms, show CSS explosion instead
|
|
setTimeout(()=>{
|
|
if(!usedVideo){
|
|
try{ vid.pause(); if(vid && vid.parentNode) vid.parentNode.removeChild(vid); }catch(_){ }
|
|
const ex = document.createElement('div'); ex.className = 'explosion'; sqEl.appendChild(ex);
|
|
requestAnimationFrame(()=> ex.classList.add('play'));
|
|
setTimeout(()=>{ try{ ex.remove(); }catch(_){ if(ex && ex.parentNode) ex.parentNode.removeChild(ex); } }, 700);
|
|
}
|
|
}, 180);
|
|
}
|
|
|
|
// 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');
|
|
} else if(type === 'owned_pawn'){
|
|
const occ = (currentBoardState.pieces||[]).find(p=>p.square===sq);
|
|
if(occ && occ.color === myShort && occ.type && occ.type.toLowerCase() === 'p') sqEl.classList.add('targetable');
|
|
} else if(type === 'enemy'){
|
|
const occ = (currentBoardState.pieces||[]).find(p=>p.square===sq);
|
|
if(occ && occ.color && 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
|
|
function buildGrid(size){
|
|
if(!size || size < 1) size = 8;
|
|
boardEl.style.gridTemplateColumns = `repeat(${size}, 1fr)`;
|
|
boardEl.style.gridTemplateRows = `repeat(${size}, 1fr)`;
|
|
// if grid already has correct number of squares, keep them
|
|
const existing = boardEl.querySelectorAll('.square');
|
|
if(existing.length === size * size) return;
|
|
boardEl.innerHTML = '';
|
|
// create rows from rank size..1 (so that a1 is bottom-left if CSS doesn't flip)
|
|
for(let rank = size; rank >= 1; rank--){
|
|
for(let file = 0; file < size; file++){
|
|
const fileLetter = String.fromCharCode('a'.charCodeAt(0) + file);
|
|
const squareName = `${fileLetter}${rank}`;
|
|
const sq = document.createElement('div');
|
|
// color squares in checker pattern
|
|
const dark = ((file + rank) % 2) === 0;
|
|
sq.className = 'square ' + (dark ? 'dark' : 'light');
|
|
sq.setAttribute('data-square', squareName);
|
|
boardEl.appendChild(sq);
|
|
}
|
|
}
|
|
}
|
|
|
|
// render player's own hand and public counts for others
|
|
// New: render as horizontal 'pokemon-style' cards side-by-side
|
|
function renderHands(handsOwn, handCounts){
|
|
const handsEl = document.getElementById('hands');
|
|
if(!handsEl) return;
|
|
handsEl.innerHTML = '';
|
|
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'player-hand pokemon-hand';
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'pokemon-hand-header';
|
|
const title = document.createElement('div');
|
|
title.textContent = 'Vos cartes';
|
|
title.className = 'pokemon-hand-title';
|
|
header.appendChild(title);
|
|
wrap.appendChild(header);
|
|
|
|
const row = document.createElement('div');
|
|
row.className = 'cards-row';
|
|
|
|
(handsOwn || []).slice(0,10).forEach(c => {
|
|
const cardEl = document.createElement('div');
|
|
cardEl.className = 'pokemon-card';
|
|
cardEl.dataset.cardId = c.cardId || c.id || '';
|
|
cardEl.dataset.cardUuid = c.id || '';
|
|
|
|
const top = document.createElement('div'); top.className = 'pokemon-card-top';
|
|
const art = document.createElement('div'); art.className = 'pokemon-card-art';
|
|
art.textContent = '';
|
|
top.appendChild(art);
|
|
|
|
const mid = document.createElement('div'); mid.className = 'pokemon-card-mid';
|
|
const h = document.createElement('div'); h.className = 'pokemon-card-title'; h.textContent = c.title || c.cardId || 'Carte';
|
|
const p = document.createElement('div'); p.className = 'pokemon-card-desc'; p.textContent = c.description || '';
|
|
mid.appendChild(h); mid.appendChild(p);
|
|
|
|
const footer = document.createElement('div'); footer.className = 'pokemon-card-footer';
|
|
const playBtn = document.createElement('button'); playBtn.className = 'card-play-btn'; playBtn.textContent = 'Jouer';
|
|
footer.appendChild(playBtn);
|
|
|
|
cardEl.appendChild(top);
|
|
cardEl.appendChild(mid);
|
|
cardEl.appendChild(footer);
|
|
|
|
// clicking the whole card or the play button emits card:play
|
|
function emitPlay(){
|
|
if(!socket) return;
|
|
const cid = cardEl.dataset.cardId;
|
|
const requireType = cardRequiresTarget(cid);
|
|
if(requireType){
|
|
if(requireType === 'player'){
|
|
// If there's only one opponent, auto-select them (no modal needed). Otherwise show modal.
|
|
const opponents = Object.keys(handCounts || {}).filter(pid => pid !== myPlayerId);
|
|
if(opponents.length === 1){
|
|
const targetPlayerId = opponents[0];
|
|
socket.emit('card:play', { roomId, playerId: myPlayerId, cardId: cid, payload: { targetPlayerId } }, (resp)=>{
|
|
if(resp && resp.error){ log('card:play error', resp); alert(resp.error); }
|
|
else { log('card played (steal card)', resp); }
|
|
});
|
|
return;
|
|
}
|
|
// show modal to pick which player to steal from
|
|
showStealModal(handCounts, (targetPlayerId)=>{
|
|
if(!targetPlayerId) return; // canceled
|
|
socket.emit('card:play', { roomId, playerId: myPlayerId, cardId: cid, payload: { targetPlayerId } }, (resp)=>{
|
|
if(resp && resp.error){ log('card:play error', resp); alert(resp.error); }
|
|
else { log('card played (steal card)', resp); }
|
|
});
|
|
});
|
|
return;
|
|
}
|
|
// 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); }
|
|
});
|
|
}
|
|
cardEl.addEventListener('click', (ev)=>{ if(ev.target === playBtn) return; emitPlay(); });
|
|
playBtn.addEventListener('click', (ev)=>{ ev.stopPropagation(); emitPlay(); });
|
|
|
|
row.appendChild(cardEl);
|
|
});
|
|
|
|
wrap.appendChild(row);
|
|
handsEl.appendChild(wrap);
|
|
}
|
|
|
|
function showCardDrawnNotification(evt){
|
|
try{
|
|
const handsEl = document.getElementById('hands');
|
|
if(!handsEl) return;
|
|
const note = document.createElement('div');
|
|
note.className = 'card-draw-note';
|
|
note.textContent = evt && evt.card ? `Pioché: ${evt.card.title || evt.card.cardId}` : 'Carte piochée';
|
|
note.style.padding = '6px 8px';
|
|
note.style.border = '1px solid #ccc';
|
|
note.style.background = '#fff8e1';
|
|
note.style.marginTop = '8px';
|
|
handsEl.prepend(note);
|
|
setTimeout(()=> note.remove(), 5000);
|
|
}catch(e){ console.warn('showCardDrawnNotification error', e); }
|
|
}
|
|
|
|
function showEffectNotification(effect){
|
|
try{
|
|
const handsEl = document.getElementById('hands');
|
|
if(!handsEl) return;
|
|
const note = document.createElement('div');
|
|
note.className = 'card-draw-note';
|
|
if(effect.type === 'promotion_failed') note.textContent = 'Promotion échouée — la carte est consommée';
|
|
else if(effect.type === 'promotion') note.textContent = 'Promotion réussie !';
|
|
else if(effect.type === 'mine_failed') note.textContent = 'Placement de mine échoué — carte consommée';
|
|
else if(effect.type === 'steal_failed') note.textContent = 'Vol échoué — carte consommée';
|
|
else if(effect.type === 'card_lost') note.textContent = 'Une de vos cartes a été volée';
|
|
else note.textContent = `Effet: ${effect.type}`;
|
|
note.style.padding = '6px 8px'; note.style.border = '1px solid #ccc'; note.style.background = '#fff8e1'; note.style.marginTop = '8px';
|
|
handsEl.prepend(note);
|
|
setTimeout(()=> note.remove(), 6000);
|
|
}catch(e){ console.warn('showEffectNotification error', e); }
|
|
}
|
|
|
|
// show a simple promotion choice modal. callback receives one of 'q','r','b','n' or null if cancelled
|
|
function showPromotionModal(square, callback){
|
|
try{
|
|
if(document.getElementById('promotionModal')) return;
|
|
const modal = document.createElement('div');
|
|
modal.id = 'promotionModal';
|
|
modal.style.position = 'fixed';
|
|
modal.style.left = '0'; modal.style.top = '0'; modal.style.right = '0'; modal.style.bottom = '0';
|
|
modal.style.display = 'flex'; modal.style.alignItems = 'center'; modal.style.justifyContent = 'center';
|
|
modal.style.background = 'rgba(0,0,0,0.45)'; modal.style.zIndex = '9999';
|
|
|
|
const box = document.createElement('div');
|
|
box.style.background = '#fff'; box.style.padding = '16px'; box.style.borderRadius = '8px'; box.style.minWidth = '320px'; box.style.textAlign = 'center';
|
|
const title = document.createElement('div'); title.textContent = 'Choisir la promotion'; title.style.fontWeight = '700'; title.style.marginBottom = '8px';
|
|
box.appendChild(title);
|
|
|
|
const desc = document.createElement('div'); desc.textContent = `Promouvoir le pion en:`; desc.style.marginBottom = '12px'; box.appendChild(desc);
|
|
|
|
const btnRow = document.createElement('div'); btnRow.style.display = 'flex'; btnRow.style.gap = '12px'; btnRow.style.justifyContent = 'center'; btnRow.style.marginBottom = '8px';
|
|
|
|
// determine the piece color at the square (should be a pawn)
|
|
const piece = currentBoardState && Array.isArray(currentBoardState.pieces) && currentBoardState.pieces.find(p=>p.square===square);
|
|
const colorPrefix = (piece && piece.color) ? (piece.color === 'w' ? 'w' : 'b') : ((myColor && myColor[0]) || 'w');
|
|
const promoTypes = [ ['q','Dame'], ['r','Tour'], ['b','Fou'], ['n','Cavalier'] ];
|
|
promoTypes.forEach(([code,label]) => {
|
|
const btn = document.createElement('button'); btn.type = 'button';
|
|
btn.style.padding = '6px'; btn.style.border = '1px solid #ccc'; btn.style.background = '#fff'; btn.style.cursor = 'pointer'; btn.style.display = 'flex'; btn.style.flexDirection = 'column'; btn.style.alignItems = 'center'; btn.style.gap = '6px';
|
|
// image
|
|
const letter = code.toUpperCase();
|
|
const img = document.createElement('img');
|
|
img.src = `${pieceSetPath}/${colorPrefix}${letter}.svg`;
|
|
img.alt = label; img.style.width = '48px'; img.style.height = '48px'; img.style.display = 'block';
|
|
// if image fails, show text label
|
|
img.addEventListener('error', ()=>{ img.style.display = 'none'; });
|
|
const lbl = document.createElement('div'); lbl.textContent = label; lbl.style.fontSize = '12px';
|
|
btn.appendChild(img); btn.appendChild(lbl);
|
|
btn.addEventListener('click', (ev)=>{ ev.stopPropagation(); try{ modal.remove(); }catch(_){ } callback(code); });
|
|
btnRow.appendChild(btn);
|
|
});
|
|
box.appendChild(btnRow);
|
|
|
|
const cancel = document.createElement('button'); cancel.type = 'button'; cancel.textContent = 'Annuler'; cancel.style.marginTop = '6px'; cancel.style.padding = '8px 12px';
|
|
cancel.addEventListener('click', (ev)=>{ ev.stopPropagation(); try{ modal.remove(); }catch(_){ } callback(null); });
|
|
box.appendChild(cancel);
|
|
|
|
modal.appendChild(box);
|
|
document.body.appendChild(modal);
|
|
}catch(e){ console.warn('showPromotionModal error', e); callback(null); }
|
|
}
|
|
|
|
// show a modal to pick a player to steal from. handCounts is an object mapping playerId -> count
|
|
function showStealModal(handCounts, callback){
|
|
try{
|
|
if(document.getElementById('stealModal')) return;
|
|
const modal = document.createElement('div'); modal.id = 'stealModal';
|
|
modal.style.position = 'fixed'; modal.style.left='0'; modal.style.top='0'; modal.style.right='0'; modal.style.bottom='0';
|
|
modal.style.display='flex'; modal.style.alignItems='center'; modal.style.justifyContent='center'; modal.style.background='rgba(0,0,0,0.45)'; modal.style.zIndex='9999';
|
|
const box = document.createElement('div'); box.style.background='#fff'; box.style.padding='12px'; box.style.borderRadius='8px'; box.style.minWidth='320px';
|
|
const title = document.createElement('div'); title.textContent = 'Choisir le joueur à voler'; title.style.fontWeight='700'; title.style.marginBottom='8px'; box.appendChild(title);
|
|
const list = document.createElement('div'); list.style.display='flex'; list.style.flexDirection='column'; list.style.gap='8px'; list.style.marginBottom='8px';
|
|
Object.entries(handCounts || {}).forEach(([pid, count])=>{
|
|
if(pid === myPlayerId) return; // skip self
|
|
const row = document.createElement('div'); row.style.display='flex'; row.style.justifyContent='space-between'; row.style.alignItems='center';
|
|
const label = document.createElement('div'); label.textContent = `${pid} — ${count} carte(s)`; label.style.fontWeight='600';
|
|
const btn = document.createElement('button'); btn.type='button'; btn.textContent = 'Voler'; btn.style.padding='6px 10px';
|
|
btn.addEventListener('click', (ev)=>{ ev.stopPropagation(); try{ modal.remove(); }catch(_){ } callback(pid); });
|
|
row.appendChild(label); row.appendChild(btn); list.appendChild(row);
|
|
});
|
|
box.appendChild(list);
|
|
const cancel = document.createElement('button'); cancel.type='button'; cancel.textContent='Annuler'; cancel.style.padding='8px 10px'; cancel.addEventListener('click',(ev)=>{ ev.stopPropagation(); try{ modal.remove(); }catch(_){ } callback(null); });
|
|
box.appendChild(cancel);
|
|
modal.appendChild(box); document.body.appendChild(modal);
|
|
}catch(e){ console.warn('showStealModal error', e); callback(null); }
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|