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; }