diff --git a/public/game.html b/public/game.html
index aac846c..8365bb7 100644
--- a/public/game.html
+++ b/public/game.html
@@ -211,6 +211,25 @@
}catch(e){ console.warn('move:moved handler error', e); }
});
+ // mine detonation: show explosion animation at the square and remove local mine visual immediately
+ socket.on('mine:detonated', (evt) => {
+ try{
+ log('recv mine:detonated', evt);
+ // evt.square is algebraic name like 'e4'
+ if(evt && evt.square) showExplosionAt(evt.square);
+ // remove any mine image/dot at that square (owner will get updated minesOwn via room:update)
+ try{
+ const sqEl = boardEl && boardEl.querySelector && boardEl.querySelector(`.square[data-square="${evt.square}"]`);
+ if(sqEl){ const img = sqEl.querySelector('.mine-img'); if(img) img.remove(); const dot = sqEl.querySelector('.mine-dot'); if(dot) dot.remove(); }
+ }catch(_){ }
+ }catch(e){ console.warn('mine:detonated handler error', e); }
+ });
+
+ // private detonation info for the mine owner (optional handling)
+ socket.on('mine:detonated:private', (evt) => {
+ try{ log('recv mine:detonated:private', evt); /* could show owner-specific UI later */ }catch(e){}
+ });
+
socket.emit('room:join', { roomId, playerId }, (resp)=>{
if(resp && resp.error){ log('join error', resp); alert(resp.error); return; }
log('Rejoint room', resp);
@@ -500,6 +519,53 @@
});
}
+ // 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;
diff --git a/public/styles/style.css b/public/styles/style.css
index b5063cc..d3fc39e 100644
--- a/public/styles/style.css
+++ b/public/styles/style.css
@@ -154,6 +154,37 @@ a:hover{text-decoration:underline}
}
.btn-cancel:hover{ background: #fff5f5; }
+/* explosion animation used when a mine detonates */
+.explosion{
+ position:absolute;
+ left:50%; top:50%; transform:translate(-50%,-50%) scale(0.6);
+ width:64%; height:64%; border-radius:50%; pointer-events:none; z-index:6;
+ background: radial-gradient(circle at 30% 30%, rgba(255,255,200,0.98) 0%, rgba(255,160,60,0.95) 40%, rgba(220,40,40,0.95) 60%, rgba(120,10,10,0.85) 100%);
+ box-shadow: 0 6px 18px rgba(0,0,0,0.35), inset 0 6px 18px rgba(255,255,255,0.08);
+ opacity: 1; transform-origin:50% 50%;
+ transform: translate(-50%,-50%) scale(0.2);
+}
+.explosion.play{ animation: explode 520ms ease-out forwards; }
+@keyframes explode{
+ 0%{ transform: translate(-50%,-50%) scale(0.2); opacity:1; filter: blur(0px); }
+ 60%{ transform: translate(-50%,-50%) scale(1.15); opacity:0.95; filter: blur(1px); }
+ 100%{ transform: translate(-50%,-50%) scale(1.6); opacity:0; filter: blur(6px); }
+}
+
+/* small debris puffs for a subtle effect */
+.explosion::after{
+ content:''; position:absolute; left:50%; top:50%; transform:translate(-50%,-50%);
+ width:120%; height:120%; border-radius:50%; background: radial-gradient(circle, rgba(255,200,100,0.6) 0%, rgba(255,200,100,0.0) 40%);
+ opacity:0; animation: debris 520ms ease-out forwards;
+}
+@keyframes debris{ 0%{ opacity:0.9; transform:translate(-50%,-50%) scale(0.8); } 100%{ opacity:0; transform:translate(-50%,-50%) scale(1.8); } }
+
+/* video-based explosion: used when a .mov/mp4 file is provided in /assets/media/ */
+.explosion-video{
+ position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); z-index:7; pointer-events:none;
+ width:160%; height:auto; max-width:220px; max-height:220px; display:block; object-fit:contain;
+}
+
/* floating small piece icons */
.floating-piece{position:absolute;opacity:0.06;width:160px;height:160px;pointer-events:none;transform:translate(-10%,-10%);}
diff --git a/server.js b/server.js
index 2daa075..990f6fa 100644
--- a/server.js
+++ b/server.js
@@ -700,18 +700,16 @@ io.on('connection', (socket) => {
for(let i = room.activeCardEffects.length - 1; i >= 0; i--){
const e = room.activeCardEffects[i];
if(e && e.type === 'mine' && e.square === to){
- // owner is immune to their own mine (assumption). Only detonate for other players.
- if(e.playerId !== senderId){
- // remove the moving piece from the board
- const rmIdx = pieces.findIndex(p => p.id === moving.id);
- if(rmIdx >= 0){ pieces.splice(rmIdx, 1); }
- // consume the mine
- try{ room.activeCardEffects.splice(i,1); }catch(_){ }
- // broadcast detonation to the room (informational)
- try{ io.to(roomId).emit('mine:detonated', { roomId: room.id, ownerId: e.playerId, detonatorId: senderId, square: to, piece: moving }); }catch(_){ }
- // privately inform the owner as well with effect id and piece details
- try{ const owner = (room.players||[]).find(p => p.id === e.playerId); if(owner && owner.socketId) io.to(owner.socketId).emit('mine:detonated:private', { roomId: room.id, effectId: e.id, square: to, piece: moving }); }catch(_){ }
- }
+ // detonate for any piece that lands on the mine (owner included)
+ // remove the moving piece from the board
+ const rmIdx = pieces.findIndex(p => p.id === moving.id);
+ if(rmIdx >= 0){ pieces.splice(rmIdx, 1); }
+ // consume the mine
+ try{ room.activeCardEffects.splice(i,1); }catch(_){ }
+ // broadcast detonation to the room (informational)
+ try{ io.to(roomId).emit('mine:detonated', { roomId: room.id, ownerId: e.playerId, detonatorId: senderId, square: to, piece: moving }); }catch(_){ }
+ // privately inform the owner as well with effect id and piece details
+ try{ const owner = (room.players||[]).find(p => p.id === e.playerId); if(owner && owner.socketId) io.to(owner.socketId).emit('mine:detonated:private', { roomId: room.id, effectId: e.id, square: to, piece: moving }); }catch(_){ }
// a mine was found/handled; break (only one mine per square expected)
break;
}