diff --git a/Makefile b/Makefile index 5ef5203..c8d39ac 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,23 @@ -all: docker +.PHONY: sync-frontend dev-sync + +all: docker sync-frontend dev-sync docker: docker-compose -f deploy/docker-compose.dev.yml up --build -d @echo "Frontend is running at http://localhost:3000" - @echo "Backend is running at http://localhost:4000" \ No newline at end of file + @echo "Backend is running at http://localhost:4000" + +sync-frontend: + @echo "Syncing frontend source -> public for dev..." + @rsync -av --exclude node_modules --exclude .git --delete frontend/src/ frontend/public/src/ + +dev-sync: sync-frontend + @echo "Done. You can run 'make dev-sync' whenever you changed frontend/src files." + +.PHONY: dev-watch +dev-watch: + @echo "Watching frontend/src for changes (requires inotifywait). Press Ctrl-C to stop." + @which inotifywait > /dev/null || (echo "Please install inotify-tools (apt install inotify-tools)" && exit 1) + while inotifywait -e modify,create,delete -r frontend/src; do \ + $(MAKE) dev-sync; \ + done \ No newline at end of file diff --git a/README.dev.md b/README.dev.md index 9c10569..b6adf77 100644 --- a/README.dev.md +++ b/README.dev.md @@ -6,3 +6,20 @@ From repo root run: docker-compose -f docker-compose.dev.yml up --build -d Then open http://localhost:3000 for the frontend. Backend API listens on http://localhost:4000 + +If you edit files under `frontend/src/` during development, run: + + make dev-sync + +This will copy changed files into `frontend/public/src/` so the static server (http://localhost:3000) serves the latest JS/CSS without rebuilding containers. + +Development workflow note +------------------------- + +Two strategies are supported during development to ensure the static server serves the latest frontend sources: + +- Symlink (used in this repo): `frontend/public/src/game.js` is a symbolic link to `../../src/game.js` so there's one source of truth. This avoids accidental drift between `src/` and `public/src/` when editing locally. Symlinks are convenient for local development but may not be appropriate for all deployment or CI environments. + +- Rsync / dev-sync: Run `make dev-sync` to copy files from `frontend/src/` into `frontend/public/src/`. This works in environments where symlinks are undesirable or when preparing files for containers. + +Choose the approach that fits your workflow. This repository currently keeps a regular copy of `game.js` in `frontend/public/src/` (to avoid symlink issues in containers/CI), and `make dev-sync` remains available as the recommended way to update the public copy during development. diff --git a/frontend/public/game.html b/frontend/public/game.html index 8d769f4..1b1217f 100644 --- a/frontend/public/game.html +++ b/frontend/public/game.html @@ -27,6 +27,7 @@

Partie

Chargement...
+
Chargement de l'état du serveur...
@@ -41,25 +42,42 @@ const qs = new URLSearchParams(window.location.search); const gid = qs.get('game'); const meta = document.getElementById('meta'); - if(!gid){ meta.textContent = 'Game ID manquant'; return; } - meta.textContent = 'Chargement de la partie ' + gid + '...'; - // fetch initial state - fetch('/api/game/' + gid).then(r => r.ok ? r.json() : null).then(g => { - if(g && g.state && typeof renderBoardFromState === 'function') renderBoardFromState(g.state); - meta.textContent = 'Partie ' + (g && g.id ? g.id : gid); - // set globals expected by src/game.js so socket handlers attach correctly - try { - if(typeof currentGameId !== 'undefined') currentGameId = gid; - if(typeof myPlayerId !== 'undefined' && g && g.players && g.players[0]) myPlayerId = g.players[0].id; - if(typeof socket === 'undefined' || !socket){ - socket = io(); - socket.on('connect', () => { socket.emit('join-game', gid); socket.emit('identify', { gameId: gid, playerId: myPlayerId }); }); - socket.on('game-state', (s) => { if(typeof renderBoardFromState === 'function') renderBoardFromState(s); if(typeof setMeta === 'function') setMeta('game-state updated'); }); - socket.on('move', (m) => { if(typeof setMeta === 'function') setMeta('remote move ' + JSON.stringify(m)); }); - socket.on('player-update', (g) => { if(typeof setMeta === 'function') setMeta('players updated'); }); + if(!gid){ meta.textContent = 'Game ID manquant'; return; } + meta.textContent = 'Affichage du plateau...'; + // render an empty board immediately so user always sees a board even if backend is slow/unavailable + try { if(typeof renderBoardFromState === 'function') renderBoardFromState(null); } catch(e){ console.warn('initial empty board render failed', e); } + // fetch initial state: prefer explicit backend host then fallback to same-origin + (function(){ + function handleGameObj(g){ + // render board from state or empty board + if(g && g.state && typeof renderBoardFromState === 'function') renderBoardFromState(g.state); + else if(typeof renderBoardFromState === 'function') renderBoardFromState(null); + meta.textContent = 'Partie ' + (g && g.id ? g.id : gid); + const sli = document.getElementById('server-load-indicator'); if(sli) sli.style.display = 'none'; + // set globals expected by src/game.js so socket handlers attach correctly + try { + currentGameId = gid; + if(g && g.players && g.players[0]) myPlayerId = g.players[0].id; + if(typeof socket === 'undefined' || !socket){ + socket = io(); + socket.on('connect', () => { socket.emit('join-game', gid); socket.emit('identify', { gameId: gid, playerId: myPlayerId }); }); + socket.on('game-state', (s) => { if(typeof renderBoardFromState === 'function') renderBoardFromState(s); if(typeof setMeta === 'function') setMeta('game-state updated'); }); + socket.on('move', (m) => { if(typeof setMeta === 'function') setMeta('remote move ' + JSON.stringify(m)); }); + socket.on('player-update', (g) => { if(typeof setMeta === 'function') setMeta('players updated'); }); + } + } catch(e){ console.warn('socket init failed', e); } + } + + fetch('http://localhost:4000/api/game/' + gid).then(r => r.ok ? r.json() : null).then(g => { + if(g) { handleGameObj(g); } + else { + // try same-origin + fetch('/api/game/' + gid).then(r => r.ok ? r.json() : null).then(handleGameObj).catch(()=>{ handleGameObj(null); const sli = document.getElementById('server-load-indicator'); if(sli) sli.textContent = 'Serveur injoignable'; }); } - } catch(e){ console.warn('socket init failed', e); } - }).catch(()=>{ meta.textContent = 'Impossible de charger la partie'; }); + }).catch(()=>{ + fetch('/api/game/' + gid).then(r => r.ok ? r.json() : null).then(handleGameObj).catch(()=>{ handleGameObj(null); const sli = document.getElementById('server-load-indicator'); if(sli) sli.textContent = 'Serveur injoignable'; }); + }); + })(); document.getElementById('leave').addEventListener('click', ()=>{ window.location.href = 'waiting.html?game=' + encodeURIComponent(gid); }); })(); diff --git a/frontend/public/src/game.js b/frontend/public/src/game.js new file mode 100644 index 0000000..611d775 --- /dev/null +++ b/frontend/public/src/game.js @@ -0,0 +1,146 @@ +const q = s => document.querySelector(s); + +// socket variable will be initialized on first load/join +let socket = null; +let myPlayerId = null; +let currentGameId = null; +let selected = null; // {x,y} + +function renderBoardFromState(state) { + const container = q('#board-container'); + if(!container){ console.error('renderBoardFromState: #board-container not found'); return; } + console.log('renderBoardFromState called, state present?', !!state); + container.innerHTML = ''; + const boardEl = document.createElement('div'); + boardEl.className = 'board'; + + // if no state provided, create an empty 8x8 board + if(!state || !state.board){ + const w = 8, h = 8; + const board = Array.from({ length: h }, () => Array.from({ length: w }, () => null)); + state = { board, width: w, height: h }; + } + + const h = state.height || state.board.length; + const w = state.width || (state.board[0] && state.board[0].length) || 8; + + // Use CSS grid so cells distribute evenly and keep the board square via wrapper's aspect-ratio + boardEl.style.display = 'grid'; + boardEl.style.gridTemplateColumns = `repeat(${w}, 1fr)`; + boardEl.style.gridTemplateRows = `repeat(${h}, 1fr)`; + + for (let r = 0; r < h; r++) { + for (let c = 0; c < w; c++) { + const cell = document.createElement('div'); + cell.className = 'cell'; + cell.dataset.x = c; + cell.dataset.y = r; + const piece = document.createElement('img'); + piece.className = 'piece'; + + const cellObj = state.board[r] && state.board[r][c]; + if (cellObj) { + const t = cellObj.type || 'PAWN'; + const color = (cellObj.color || 'white').toLowerCase(); + const lookup = { + PAWN: `assets/chess-pieces/chess_maestro_bw/${color[0] === 'w' ? 'wP' : 'bP'}.svg`, + ROOK: `assets/chess-pieces/chess_maestro_bw/${color[0] === 'w' ? 'wR' : 'bR'}.svg`, + KNIGHT: `assets/chess-pieces/chess_maestro_bw/${color[0] === 'w' ? 'wN' : 'bN'}.svg`, + BISHOP: `assets/chess-pieces/chess_maestro_bw/${color[0] === 'w' ? 'wB' : 'bB'}.svg`, + QUEEN: `assets/chess-pieces/chess_maestro_bw/${color[0] === 'w' ? 'wQ' : 'bQ'}.svg`, + KING: `assets/chess-pieces/chess_maestro_bw/${color[0] === 'w' ? 'wK' : 'bK'}.svg`, + }; + piece.src = lookup[t] || lookup.PAWN; + } else { + piece.style.display = 'none'; + } + + cell.appendChild(piece); + boardEl.appendChild(cell); + // click to select/move + cell.addEventListener('click', () => { + // if no game/socket, ignore + if (!currentGameId) return; + const x = parseInt(cell.dataset.x, 10); + const y = parseInt(cell.dataset.y, 10); + if (!selected) { + selected = { x, y }; + cell.classList.add('selected'); + setMeta('Selected ' + x + ',' + y); + } else { + // send move + const from = selected; + const to = { x, y }; + selected = null; + // clear previously selected class + document.querySelectorAll('.cell.selected').forEach(el => el.classList.remove('selected')); + setMeta('Sending move ' + from.x + ',' + from.y + ' -> ' + to.x + ',' + to.y); + if (socket && myPlayerId && currentGameId) socket.emit('move', { gameId: currentGameId, playerId: myPlayerId, from, to }); + } + }); + } + } + container.appendChild(boardEl); + console.log('renderBoardFromState: board appended (w=' + w + ', h=' + h + ')'); +} + +function setMeta(text) { q('#meta').textContent = text; } + +q('#create-only')?.addEventListener('click', async () => { + const name = q('#game-name').value || 'game-from-ui'; + const res = await fetch('/api/create', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ name, ownerNickname: 'ui-host' }) }); + const j = await res.json(); + setMeta('Created: ' + j.game.id); + q('#game-id-input').value = j.game.id; +}); + +q('#create-start')?.addEventListener('click', async () => { + const name = q('#game-name').value || 'game-from-ui'; + // create + const createResp = await fetch('/api/create', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ name, ownerNickname: 'ui-host' }) }); + const createJson = await createResp.json(); + const id = createJson.game.id; + setMeta('Created: ' + id + ' — joining as second player...'); + // join as second player + await fetch('/api/join', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ id, nickname: 'ui-guest' }) }); + setMeta('Joined — starting...'); + const startResp = await fetch('/api/start', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ id }) }); + const startJson = await startResp.json(); + setMeta('Started: ' + id); + if (startJson.game && startJson.game.state) renderBoardFromState(startJson.game.state); + // connect socket and join room as the guest player we created earlier + currentGameId = id; + // try to capture the second player id from server game.players + const players = startJson.game.players || []; + if (players[1]) myPlayerId = players[1].id; else if (players[0]) myPlayerId = players[0].id; + if (!socket) { + socket = io(); + socket.on('connect', () => { socket.emit('join-game', currentGameId); socket.emit('identify', { gameId: currentGameId, playerId: myPlayerId }); }); + socket.on('game-state', (s) => { renderBoardFromState(s); setMeta('game-state updated'); }); + socket.on('move', (m) => { setMeta('remote move ' + JSON.stringify(m)); }); + socket.on('player-update', (g) => { setMeta('players updated'); }); + socket.on('error', (e) => { console.warn('socket error', e); setMeta('Socket error: ' + (e && e.error)); }); + } +}); + +q('#load')?.addEventListener('click', async () => { + const id = q('#game-id-input').value; + if(!id) return setMeta('Enter a game id'); + const res = await fetch('/api/game/' + id).catch(e => null); + if(!res || !res.ok) return setMeta('Game not found or backend unreachable'); + const j = await res.json(); + setMeta('Loaded: ' + id + ' (started=' + !!j.started + ')'); + if (j.state && j.state.board) renderBoardFromState(j.state); + // wire socket to listen to updates in this game + currentGameId = id; + // choose a player id if any exists (by default pick first) + if (j.players && j.players.length > 0) myPlayerId = j.players[0].id; + if (!socket) { + socket = io(); + socket.on('connect', () => { socket.emit('join-game', currentGameId); socket.emit('identify', { gameId: currentGameId, playerId: myPlayerId }); }); + socket.on('game-state', (s) => { renderBoardFromState(s); setMeta('game-state updated'); }); + socket.on('move', (m) => { setMeta('remote move ' + JSON.stringify(m)); }); + socket.on('player-update', (g) => { setMeta('players updated'); }); + socket.on('error', (e) => { console.warn('socket error', e); setMeta('Socket error: ' + (e && e.error)); }); + } +}); diff --git a/frontend/src/game.js b/frontend/src/game.js index ec0b0bc..611d775 100644 --- a/frontend/src/game.js +++ b/frontend/src/game.js @@ -8,6 +8,8 @@ let selected = null; // {x,y} function renderBoardFromState(state) { const container = q('#board-container'); + if(!container){ console.error('renderBoardFromState: #board-container not found'); return; } + console.log('renderBoardFromState called, state present?', !!state); container.innerHTML = ''; const boardEl = document.createElement('div'); boardEl.className = 'board'; @@ -79,6 +81,7 @@ function renderBoardFromState(state) { } } container.appendChild(boardEl); + console.log('renderBoardFromState: board appended (w=' + w + ', h=' + h + ')'); } function setMeta(text) { q('#meta').textContent = text; }