ChessNut/public/game.html
2025-11-29 19:50:22 +01:00

1397 lines
72 KiB
HTML

<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>ChessNut - Partie</title>
<link rel="stylesheet" href="/styles/style.css">
<style>
/* veil overlay for brouillard effect */
.square { position: relative; }
.square .veil {
position: absolute;
left: 0; top: 0; right: 0; bottom: 0;
background: rgba(255,255,255,0.95);
pointer-events: auto; /* block interactions so veiled squares are not clickable */
z-index: 50;
cursor: default;
}
/* activity feed for user-facing logs (card plays) */
#activityFeed { max-height: 140px; overflow:auto; padding:8px; background:#fff; border:1px solid #eee; border-radius:6px; font-size:14px; color:#222 }
#log { display:none; }
</style>
</head>
<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>
<div id="turnBanner" style="margin-bottom:8px"></div>
<div>Room: <strong id="roomId">-</strong> &nbsp;&nbsp; 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>
<div style="margin-top:8px;display:flex;gap:8px;align-items:center">
<pre id="log" style="margin-top:0px;height:140px;overflow:auto;flex:1">logs...</pre>
</div>
<div style="margin-top:8px">
<div><strong>Activité</strong></div>
<div id="activityFeed" style="margin-top:8px">Aucune activité pour l'instant.</div>
</div>
</section>
<div style="margin-top:10px;display:flex;align-items:center;gap:12px">
<div id="hands" style="display:flex;flex-direction:column;gap:8px;flex:1;margin-left:6px"></div>
</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;
// map playerId -> color (kept up-to-date from room:update)
let playerColors = {};
// 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 = {};
// whether this client stayed to spectate after game over
let isSpectating = false;
// track remote moves: { playerId: [moves] }
const remoteMoves = {};
// current boardState cached locally
let currentBoardState = null;
let myMines = [];
let currentVeiledSquares = [];
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);
}
}
// append a user-facing activity message (visible to players). Keeps recent N entries.
function appendActivity(message){
try{
const feed = document.getElementById('activityFeed');
if(!feed) return;
const line = document.createElement('div');
// show only the message (no timestamp)
line.textContent = message;
// prepend newest at top
if(feed.textContent && feed.textContent.indexOf("Aucune activité") !== -1){ feed.textContent = ''; }
feed.insertBefore(line, feed.firstChild);
// limit to 30 entries
while(feed.children.length > 30) feed.removeChild(feed.lastChild);
}catch(_){ }
}
// small toast notification helper (non-blocking)
function showToast(message, opts){
try{
opts = opts || {};
const id = 'cn-toast';
const wrap = document.createElement('div');
wrap.className = 'cn-toast';
wrap.textContent = message || '';
wrap.style.position = 'fixed';
wrap.style.left = '50%';
wrap.style.transform = 'translateX(-50%)';
wrap.style.top = opts.top || '18px';
wrap.style.background = opts.background || 'rgba(0,0,0,0.8)';
wrap.style.color = opts.color || '#fff';
wrap.style.padding = '8px 12px';
wrap.style.borderRadius = '6px';
wrap.style.zIndex = 99999;
wrap.style.fontSize = '14px';
wrap.style.boxShadow = '0 6px 18px rgba(0,0,0,0.25)';
document.body.appendChild(wrap);
setTimeout(()=>{ try{ wrap.style.transition = 'opacity 300ms ease'; wrap.style.opacity = '0'; setTimeout(()=>{ try{ wrap.remove(); }catch(_){ } }, 320); }catch(_){ } }, opts.duration || 3500);
}catch(_){ }
}
// Robust card:play emitter with refresh fallback.
// Sometimes the client or network misses the room:update after playing a card —
// this helper emits card:play and requests a room refresh if no ack/update is received.
function emitCardPlay(payload, cb){
try{
if(!socket) return cb && cb({ error: 'no_socket' });
let acked = false;
const timer = setTimeout(()=>{
if(!acked){
try{ socket.emit('room:refresh', { roomId }, ()=>{}); }catch(_){ }
}
}, 1500);
socket.emit('card:play', payload, (resp)=>{
acked = true; try{ clearTimeout(timer); }catch(_){ }
// ask server to push a fresh room:update to ensure UIs are in sync
try{ socket.emit('room:refresh', { roomId }, ()=>{}); }catch(_){ }
if(cb) cb(resp);
});
}catch(e){ if(cb) cb({ error: 'emit_failed' }); }
}
// play a short shuffle animation (shake + fade) when pieces are mixed
function playShuffleAnimation(){
if(!boardEl) return;
try{
boardEl.classList.add('shuffle-play');
// remove after animation duration
setTimeout(()=>{ try{ boardEl.classList.remove('shuffle-play'); }catch(_){ } }, 700);
}catch(_){ }
}
// helper: decide if a card requires a target selected after clicking 'Jouer'
function cardRequiresTarget(cardId){
if(!cardId) return null;
// normalize and strip diacritics so titles like "Téléportation" become "teleportation"
let id = String(cardId).toLowerCase();
try{
id = id.normalize('NFD').replace(/\p{Diacritic}/gu, '');
}catch(e){
// fallback: remove non-ascii characters
id = id.replace(/[^\x00-\x7F]/g, '');
}
if(id.indexOf('mine') !== -1) return 'empty';
// revolution is a global immediate effect: it should NOT require any piece selection
if(id.indexOf('revol') !== -1) return null;
// sniper: select one of your pieces to bind the sniper effect (capture later without moving)
if(id.indexOf('sniper') !== -1) return 'owned';
// doppelganger: select one of your pieces after playing the card (minimal flow)
if(id.indexOf('doppel') !== -1) return 'owned';
// 'toucher c'est jouer' : require selecting an enemy piece as the card target
if(id.indexOf('toucher') !== -1) return 'enemy';
// la parrure: select an enemy queen to downgrade to a pawn
if(id.indexOf('parrure') !== -1) return 'enemy_queen';
// tout ou rien: select a piece (any, except kings) to force it to only move when capturing
if(id.indexOf('tout') !== -1 && id.indexOf('rien') !== -1) return 'piece';
// 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';
// teleportation: require selecting one of your pieces first (like promotion)
if(id.indexOf('teleport') !== -1 || id.indexOf('tele') !== -1 || id.indexOf('t_l_portation') !== -1) return 'owned';
// inversion: first choose one of your pieces, then choose an enemy piece to swap places
if(id.indexOf('inversion') !== -1) return 'own_then_enemy';
return null;
}
if(!roomId){ showToast('roomId manquant dans l\'URL', { background: 'rgba(200,60,60,0.95)' }); }
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=>{
// keep a mapping from playerId -> color for activity feed formatting
try{ playerColors[p.id] = p.color; }catch(_){ }
// if this is our player, store the color locally and update UI
try{ if(p.id === myPlayerId){ myColor = p.color; if(myColorEl) myColorEl.textContent = myColor; } }catch(_){ }
const li = document.createElement('li');
li.textContent = (p.color || 'Joueur') + (p.id === myPlayerId ? ' (vous)' : '');
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';
}
// store veiled squares list for brouillard effect (if provided by server)
try{ currentVeiledSquares = Array.isArray(data.veiledSquares) ? data.veiledSquares : (data.boardState && data.boardState.veiledSquares) || []; }catch(_){ currentVeiledSquares = []; }
if(data.boardState) {
renderBoardFromState(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)
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 || {});
}
// show/hide draw button: only when autoDraw is OFF and it's this player's turn
try{
const drawBtn = document.getElementById('drawBtn');
if(drawBtn){
const auto = !!data.autoDraw;
const board = data.boardState || null;
const myShort = myColor && myColor[0];
if(!auto && board && myShort && board.turn === myShort){
// enable draw if hand not full
const myHandLen = (data.handsOwn && data.handsOwn.length) || 0;
drawBtn.style.display = 'inline-block';
drawBtn.disabled = myHandLen >= 5;
} else {
drawBtn.style.display = 'none';
}
}
}catch(_){ }
});
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); } });
// 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); }
});
// public notice that a player drew a card when autoDraw is OFF (server emits this only in that case)
socket.on('player:drew', (data) => {
try{
const pid = data && data.playerId;
if(!pid) return;
const actor = (pid === myPlayerId) ? 'Vous' : (playerColors[pid] ? playerColors[pid] : 'Adversaire');
// show owner message as 'Vous : a pioché une carte' so the player sees the activity line too
appendActivity(`${actor} : a pioché une carte`);
}catch(_){ }
});
// when server broadcasts generic room updates, it includes autoDraw flag
// ensure draw button visibility is updated inside the room:update handler below
// 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();
}
});
}
}
// show friendly activity: who played which card (use title if available)
try{
const title = (cardObj && (cardObj.title || cardObj.cardId)) || (played && played.card && (played.card.title || played.card.cardId)) || 'Carte';
// show player's color instead of their id; fallback to generic label
const actor = (owner === myPlayerId) ? 'Vous' : (playerColors[owner] ? playerColors[owner] : 'Adversaire');
// If the played card is marked hidden, do not reveal the title to other players
const isHidden = (cardObj && cardObj.hidden) || (played && played.card && played.card.hidden);
if(isHidden && owner !== myPlayerId){
appendActivity(`${actor} : a joué une carte`);
} else {
appendActivity(`${actor} : a joué la carte "${title}"`);
}
}catch(_){ }
}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;
// play shuffle animation for melange
if(effect.type === 'melange' || effect.type === 'm\u00E9lange'){
try{ playShuffleAnimation(); }catch(_){ }
}
// 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){ }
});
// private event: your played card was blocked (e.g., by a totem)
socket.on('card:play_blocked:private', (data) => {
try{
log('recv card:play_blocked:private', data);
const msg = (data && data.message) || 'Votre carte a été annulée par un effet.';
// show a toast and also log in the side log
showToast(msg, { background: 'rgba(200,60,60,0.95)' });
if(logEl) { log('notification:', msg); }
}catch(e){ console.warn('card:play_blocked:private handler error', e); }
});
// generic notification channel for targeted messages
socket.on('notification', (data) => {
try{
log('recv notification', data);
const msg = data && data.message ? data.message : (data && data.text) || 'Notification';
showToast(msg, { background: 'rgba(40,40,40,0.95)' });
}catch(e){ console.warn('notification handler error', 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); showToast(resp.message || resp.error, { background: 'rgba(200,60,60,0.95)' }); 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');
}
}
// 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
// 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;
// do not show legal-move markers on squares that are veiled/hidden
try{
if(Array.isArray(currentVeiledSquares) && currentVeiledSquares.indexOf(to) !== -1) return;
if(target.classList && target.classList.contains('veiled')) return;
}catch(_){ }
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;
// do not show remote move markers on veiled/hidden squares
try{
if(Array.isArray(currentVeiledSquares) && currentVeiledSquares.indexOf(to) !== -1) return;
if(target.classList && target.classList.contains('veiled')) return;
}catch(_){ }
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;
// Do not allow interacting with veiled/hidden squares.
// `currentVeiledSquares` is populated from server `room:update` and
// we also add a `.veiled` class during rendering as a fallback.
try{
if(Array.isArray(currentVeiledSquares) && currentVeiledSquares.indexOf(square) !== -1){
log('clicked veiled square, ignoring');
return;
}
if(sqEl.classList && sqEl.classList.contains('veiled')){ log('clicked veiled square (DOM class), ignoring'); return; }
}catch(_){ }
// 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' | 'own_then_enemy' | 'enemy_queen'
const myShort = (myColor && myColor[0]) || '';
// handle two-step selection for cards like 'inversion'
if(req === 'own_then_enemy'){
// first select one of your own pieces
if(!pendingCard.firstTarget){
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 (first)'); return; }
if(piece.type && piece.type.toLowerCase() === 'k'){ log('cannot select king'); return; }
// store first selection and highlight enemy pieces
pendingCard.firstTarget = square;
highlightTargets('enemy');
// mark visually the chosen source (temporary)
try{ const el = boardEl.querySelector(`.square[data-square="${square}"]`); if(el) el.classList.add('target-chosen'); }catch(_){ }
log('First piece selected for inversion:', square);
return;
}
// second selection: enemy piece to swap with
const occ = currentBoardState && Array.isArray(currentBoardState.pieces) && currentBoardState.pieces.find(p => p.square === square);
if(!occ || occ.color === myShort){ log('select an enemy piece (second)'); return; }
// emit swap payload: sourceSquare + targetSquare
emitCardPlay({ roomId, playerId: myPlayerId, cardId: pendingCard.cardId, payload: { sourceSquare: pendingCard.firstTarget, targetSquare: square } }, (resp)=>{
if(resp && resp.error){ log('card:play error', resp); showToast(resp.message || resp.error, { background: 'rgba(200,60,60,0.95)' }); }
else { log('inversion played', resp); }
});
// cleanup visuals
try{ const el = boardEl.querySelector(`.square[data-square="${pendingCard.firstTarget}"]`); if(el) el.classList.remove('target-chosen'); }catch(_){ }
pendingCard = null; clearTargetHighlights(); return;
}
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
emitCardPlay({ roomId, playerId: myPlayerId, cardId: pendingCard.cardId, payload: { targetSquare: square } }, (resp)=>{
if(resp && resp.error){ log('card:play error', resp); showToast(resp.message || resp.error, { background: 'rgba(200,60,60,0.95)' }); }
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; }
if(piece.type && piece.type.toLowerCase() === 'k'){ log('cannot select king'); return; }
emitCardPlay({ roomId, playerId: myPlayerId, cardId: pendingCard.cardId, payload: { targetSquare: square } }, (resp)=>{
if(resp && resp.error){ log('card:play error', resp); showToast(resp.message || resp.error, { background: 'rgba(200,60,60,0.95)' }); }
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;
}
emitCardPlay({ roomId, playerId: myPlayerId, cardId: pendingCard.cardId, payload: { targetSquare: square, promotion: promotionChoice } }, (resp)=>{
if(resp && resp.error){ log('card:play error', resp); showToast(resp.message || resp.error, { background: 'rgba(200,60,60,0.95)' }); }
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; }
if(occ.type && occ.type.toLowerCase() === 'k'){ log('cannot select king'); return; }
emitCardPlay({ roomId, playerId: myPlayerId, cardId: pendingCard.cardId, payload: { targetSquare: square } }, (resp)=>{
if(resp && resp.error){ log('card:play error', resp); showToast(resp.message || resp.error, { background: 'rgba(200,60,60,0.95)' }); }
else { log('card played with target', resp);
// after server applied the card effect, request legal moves for the selected piece so the client
// will receive the updated legal moves (including teleport to empty squares)
try{ setSelection(square); }catch(_){ }
}
});
// keep pendingCard until server response processed (client will call setSelection on success)
pendingCard = null; clearTargetHighlights(); return;
}
else if(req === 'enemy_queen'){
const occ = currentBoardState && Array.isArray(currentBoardState.pieces) && currentBoardState.pieces.find(p => p.square === square);
if(!occ || occ.color === myShort || !occ.type || occ.type.toLowerCase() !== 'q'){ log('select an enemy queen'); return; }
emitCardPlay({ roomId, playerId: myPlayerId, cardId: pendingCard.cardId, payload: { targetSquare: square } }, (resp)=>{
if(resp && resp.error){ log('card:play error', resp); showToast(resp.message || resp.error, { background: 'rgba(200,60,60,0.95)' }); }
else { log('card played with target', resp);
try{ setSelection(square); }catch(_){ }
}
});
pendingCard = null; clearTargetHighlights(); return;
}
else if(req === 'piece'){
const occ = currentBoardState && Array.isArray(currentBoardState.pieces) && currentBoardState.pieces.find(p => p.square === square);
if(!occ){ log('select a piece'); return; }
if(occ.type && occ.type.toLowerCase() === 'k'){ log('cannot select king'); return; }
emitCardPlay({ roomId, playerId: myPlayerId, cardId: pendingCard.cardId, payload: { targetSquare: square } }, (resp)=>{
if(resp && resp.error){ log('card:play error', resp); showToast(resp.message || resp.error, { background: 'rgba(200,60,60,0.95)' }); }
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); showToast(resp.message || resp.error, { background: 'rgba(200,60,60,0.95)' }); }
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){ }
// render veils for brouillard effect (if any)
try{
// remove existing veils
const allSq = boardEl.querySelectorAll('.square');
allSq.forEach(sqEl => {
const v = sqEl.querySelector('.veil'); if(v) v.remove();
// also remove prior veiled marker class
try{ sqEl.classList.remove('veiled'); }catch(_){ }
});
if(Array.isArray(currentVeiledSquares) && currentVeiledSquares.length){
currentVeiledSquares.forEach(sq => {
const el = boardEl.querySelector(`.square[data-square="${sq}"]`);
if(!el) return;
const veil = document.createElement('div'); veil.className = 'veil';
el.appendChild(veil);
// add a class to hide piece images as a fallback in case z-index/stacking causes overlay issues
try{
el.classList.add('veiled');
// remove any move markers that may have been added previously on this square
const prevMarkers = el.querySelectorAll('.move-marker');
prevMarkers.forEach(m => m.remove());
}catch(_){ }
});
}
}catch(_){ }
}
// 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 && occ.type && occ.type.toLowerCase() !== 'k') 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 === 'own_then_enemy'){
// initial phase: highlight only own pieces (client will switch to enemy after first selection)
const occ = (currentBoardState.pieces||[]).find(p=>p.square===sq);
if(occ && occ.color === myShort && occ.type && occ.type.toLowerCase() !== 'k') sqEl.classList.add('targetable');
} else if(type === 'piece'){
const occ = (currentBoardState.pieces||[]).find(p=>p.square===sq);
if(occ && occ.type && occ.type.toLowerCase() !== 'k') sqEl.classList.add('targetable');
} else if(type === 'enemy_queen'){
const occ = (currentBoardState.pieces||[]).find(p=>p.square===sq);
if(occ && occ.color && occ.color !== myShort && occ.type && occ.type.toLowerCase() === 'q') sqEl.classList.add('targetable');
} else if(type === 'enemy'){
const occ = (currentBoardState.pieces||[]).find(p=>p.square===sq);
if(occ && occ.color && occ.color !== myShort && occ.type && occ.type.toLowerCase() !== 'k') 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 = '';
// show card artwork if available in assets/img/cards matching the cardId or title
try{
const cid = (c.cardId || c.id || '').toString().toLowerCase();
const title = (c.title || '').toString().toLowerCase();
let imgSrc = null;
if(cid === 'adoubement' || title.indexOf('adoub') !== -1) imgSrc = '/assets/img/cards/adoubement.png';
if(cid === 'folie' || title.indexOf('folie') !== -1 || title.indexOf('fou') !== -1) imgSrc = '/assets/img/cards/folie.png';
if(cid === 'fortification' || title.indexOf('fortification') !== -1 || title.indexOf('fortif') !== -1) imgSrc = '/assets/img/cards/fortification.png';
if(cid === 'rebond' || title.indexOf('rebond') !== -1) imgSrc = '/assets/img/cards/rebond.png';
if(cid === 'anneau' || title.indexOf('anneau') !== -1) imgSrc = '/assets/img/cards/anneau.png';
if(cid === 'brouillard' || title.indexOf('brouillard') !== -1) imgSrc = '/assets/img/cards/brouillard.png';
if(cid === 'coin' || title.indexOf('coin') !== -1) imgSrc = '/assets/img/cards/coincoin.png';
if(cid === 'inversion' || title.indexOf('inversion') !== -1) imgSrc = '/assets/img/cards/inversion.png';
if(cid === 'invisible' || title.indexOf('invisible') !== -1) imgSrc = '/assets/img/cards/invisible.png';
if(cid === 'kamikaz' || title.indexOf('kamikaz') !== -1) imgSrc = '/assets/img/cards/kamikaz.png';
if(cid === 'melange' || title.indexOf('melange') !== -1 || title.indexOf('melange') !== -1) imgSrc = '/assets/img/cards/mélange.png';
if(cid === 'mine' || title.indexOf('mine') !== -1) imgSrc = '/assets/img/cards/mine.png';
if(cid === 'promotion' || title.indexOf('promotion') !== -1) imgSrc = '/assets/img/cards/promotion.png';
if(cid === 'resurection' || title.indexOf('resurection') !== -1 || title.indexOf('résurection') !== -1) imgSrc = '/assets/img/cards/resurection.png';
if(cid === 'sniper' || title.indexOf('sniper') !== -1) imgSrc = '/assets/img/cards/sniper.png';
if(cid === 'totem' || title.indexOf('totem') !== -1) imgSrc = '/assets/img/cards/totem.png';
if(cid === 'toucher' || title.indexOf('toucher') !== -1) imgSrc = '/assets/img/cards/toucher.png';
if(cid === 'vole' || title.indexOf('vole') !== -1 || title.indexOf('vole') !== -1) imgSrc = '/assets/img/cards/vole_piece.png';
if(imgSrc){
const img = document.createElement('img');
img.src = imgSrc;
img.alt = c.title || cid || 'card';
img.className = 'card-art-img';
img.style.width = '100%'; img.style.height = '100%'; img.style.objectFit = 'cover';
art.appendChild(img);
}
}catch(e){ /* ignore artwork errors */ }
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;
// try cardId first, then fall back to the human title (helps with accented ids like "Téléportation")
const requireType = cardRequiresTarget(cid) || cardRequiresTarget(c.title || '');
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];
emitCardPlay({ roomId, playerId: myPlayerId, cardId: cid, payload: { targetPlayerId } }, (resp)=>{
if(resp && resp.error){ log('card:play error', resp); showToast(resp.message || resp.error, { background: 'rgba(200,60,60,0.95)' }); }
else { log('card played (steal card)', resp); }
});
return;
}
// show modal to pick which player to steal from
showStealModal(handCounts, (targetPlayerId)=>{
if(!targetPlayerId) return; // canceled
emitCardPlay({ roomId, playerId: myPlayerId, cardId: cid, payload: { targetPlayerId } }, (resp)=>{
if(resp && resp.error){ log('card:play error', resp); showToast(resp.message || resp.error, { background: 'rgba(200,60,60,0.95)' }); }
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 = {};
emitCardPlay({ roomId, playerId: myPlayerId, cardId: cid, payload }, (resp)=>{
if(resp && resp.error){ log('card:play error', resp); showToast(resp.message || resp.error, { background: 'rgba(200,60,60,0.95)' }); }
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);
});
// draw button inside the 'Vos cartes' header (visible only when autoDraw is off and it's your turn)
try{
const drawBtn = document.createElement('button');
drawBtn.id = 'drawBtn';
drawBtn.type = 'button';
drawBtn.className = 'btn-draw';
drawBtn.style.display = 'none';
drawBtn.textContent = 'Piocher';
// attach handler that emits player:draw and temporarily disables the button
drawBtn.addEventListener('click', (ev)=>{
ev.stopPropagation();
if(!socket) return;
drawBtn.disabled = true;
socket.emit('player:draw', { roomId }, (resp)=>{
if(resp && resp.error){ log('player:draw error', resp); showToast(resp.message || resp.error, { background: 'rgba(200,60,60,0.95)' }); drawBtn.disabled = false; return; }
log('player:draw ok', resp);
// server will send room:update which will hide/disable the button as needed
});
});
header.appendChild(drawBtn);
}catch(_){ }
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>