492 lines
20 KiB
HTML
492 lines
20 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>
|
|
<section class="card" id="handsSection" style="margin-top:12px">
|
|
<div><strong>Cartes du joueur</strong></div>
|
|
<div id="hands" style="margin-top:8px;display:flex;flex-direction:column;gap:8px"></div>
|
|
</section>
|
|
</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;
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
// render hands: server now sends private hands as `handsOwn` and public counts as `handCounts`
|
|
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); }
|
|
});
|
|
// 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); }
|
|
});
|
|
|
|
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 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 -> clear selection
|
|
clearSelection();
|
|
});
|
|
}
|
|
|
|
// 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);
|
|
});
|
|
}
|
|
|
|
// 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
|
|
function renderHands(handsOwn, handCounts){
|
|
const handsEl = document.getElementById('hands');
|
|
if(!handsEl) return;
|
|
handsEl.innerHTML = '';
|
|
// show our own hand
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'player-hand';
|
|
const title = document.createElement('div');
|
|
title.style.fontWeight = '700';
|
|
title.style.marginBottom = '6px';
|
|
title.textContent = 'Votre main';
|
|
wrap.appendChild(title);
|
|
const list = document.createElement('div');
|
|
list.style.display = 'flex';
|
|
list.style.flexDirection = 'column';
|
|
list.style.gap = '6px';
|
|
(handsOwn || []).slice(0,5).forEach(c => {
|
|
const cardEl = document.createElement('div');
|
|
cardEl.className = 'card-item';
|
|
const img = document.createElement('div');
|
|
img.className = 'card-art';
|
|
img.textContent = 'Illustration';
|
|
const meta = document.createElement('div');
|
|
meta.className = 'card-meta';
|
|
const h = document.createElement('div'); h.className = 'card-title'; h.textContent = c.title || c.cardId || 'Carte';
|
|
const p = document.createElement('div'); p.className = 'card-desc'; p.textContent = c.description || '';
|
|
meta.appendChild(h); meta.appendChild(p);
|
|
cardEl.appendChild(img);
|
|
cardEl.appendChild(meta);
|
|
list.appendChild(cardEl);
|
|
});
|
|
wrap.appendChild(list);
|
|
handsEl.appendChild(wrap);
|
|
|
|
// show counts for other players
|
|
Object.entries(handCounts || {}).forEach(([pid, count]) => {
|
|
if(pid === myPlayerId) return;
|
|
const other = document.createElement('div');
|
|
other.className = 'player-hand';
|
|
const t = document.createElement('div');
|
|
t.style.fontWeight = '700';
|
|
t.style.marginBottom = '6px';
|
|
t.textContent = `Main de ${pid} — ${count} carte(s)`;
|
|
other.appendChild(t);
|
|
handsEl.appendChild(other);
|
|
});
|
|
}
|
|
|
|
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); }
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|