diff --git a/src/server-deno/presentation/WebSocketServer.ts b/src/server-deno/presentation/WebSocketServer.ts index 7a212d5..5b1a881 100644 --- a/src/server-deno/presentation/WebSocketServer.ts +++ b/src/server-deno/presentation/WebSocketServer.ts @@ -508,7 +508,13 @@ export class WebSocketServer { const timeline = room.state.timeline[player.id] || []; if (result.correct && timeline.length >= room.state.goal) { room.state.status = GameStatus.ENDED; - this.broadcast(room, WS_EVENTS.GAME_ENDED, { winner: player.id }); + const winnerPlayer = room.players.get(player.id); + this.broadcast(room, WS_EVENTS.GAME_ENDED, { + winner: player.id, + winnerName: winnerPlayer?.name || player.id.slice(0, 4), + score: timeline.length, + timeline: timeline, + }); } } catch (error) { logger.error(`Place guess error: ${error}`); @@ -632,7 +638,15 @@ export class WebSocketServer { const track = await this.gameService.drawNextTrack(room); if (!track) { - this.broadcast(room, WS_EVENTS.GAME_ENDED, { winner: this.gameService.getWinner(room) }); + const winnerId = this.gameService.getWinner(room); + const winnerPlayer = winnerId ? room.players.get(winnerId) : null; + const winnerTimeline = winnerId ? (room.state.timeline[winnerId] || []) : []; + this.broadcast(room, WS_EVENTS.GAME_ENDED, { + winner: winnerId, + winnerName: winnerPlayer?.name || (winnerId ? winnerId.slice(0, 4) : 'Unknown'), + score: winnerTimeline.length, + timeline: winnerTimeline, + }); this.stopSyncTimer(room); return; } diff --git a/src/server-deno/public/index.html b/src/server-deno/public/index.html index 01ad500..b350b48 100644 --- a/src/server-deno/public/index.html +++ b/src/server-deno/public/index.html @@ -32,6 +32,55 @@ #dashboard[open] .dashboard-chevron { transform: rotate(90deg); } + + /* Winner popup animations */ + @keyframes confetti-fall { + 0% { + transform: translateY(0) rotate(0deg); + opacity: 1; + } + + 100% { + transform: translateY(100vh) rotate(720deg); + opacity: 0; + } + } + + @keyframes popup-entrance { + 0% { + transform: scale(0.5) translateY(20px); + opacity: 0; + } + + 50% { + transform: scale(1.05); + } + + 100% { + transform: scale(1) translateY(0); + opacity: 1; + } + } + + @keyframes bounce-slow { + + 0%, + 100% { + transform: translateX(-50%) translateY(0); + } + + 50% { + transform: translateX(-50%) translateY(-10px); + } + } + + .animate-popup { + animation: popup-entrance 0.5s ease-out forwards; + } + + .animate-bounce-slow { + animation: bounce-slow 1.5s ease-in-out infinite; + } diff --git a/src/server-deno/public/js/handlers.js b/src/server-deno/public/js/handlers.js index e00fdda..a695d22 100644 --- a/src/server-deno/public/js/handlers.js +++ b/src/server-deno/public/js/handlers.js @@ -167,7 +167,107 @@ export function handleReveal(msg) { } export function handleGameEnded(msg) { - alert(`Gewinner: ${shortName(msg.winner)}`); + // Create and show the winner popup with confetti + showWinnerPopup(msg); +} + +function showWinnerPopup(msg) { + const winnerName = msg.winnerName || shortName(msg.winner); + const score = msg.score ?? 0; + const timeline = msg.timeline || []; + + // Create the popup overlay + const overlay = document.createElement('div'); + overlay.id = 'winnerOverlay'; + overlay.className = 'fixed inset-0 z-50 flex items-center justify-center p-4'; + overlay.style.cssText = 'background: rgba(0,0,0,0.75); backdrop-filter: blur(4px);'; + + // Create timeline cards HTML + const timelineHtml = timeline.length > 0 + ? timeline.map(t => ` +
+
${t.year ?? '?'}
+
+
${escapeHtmlSimple(t.title || 'Unknown')}
+
${escapeHtmlSimple(t.artist || '')}
+
+
+ `).join('') + : '

Keine Karten

'; + + overlay.innerHTML = ` +
+
+
🏆
+

Gewinner!

+

${escapeHtmlSimple(winnerName)}

+

Score: ${score} Karten

+
+

Zeitleiste

+
+ ${timelineHtml} +
+
+ +
+ `; + + document.body.appendChild(overlay); + + // Start confetti animation + createConfetti(); + + // Close button handler + document.getElementById('closeWinnerBtn').addEventListener('click', () => { + overlay.remove(); + }); + + // Close on overlay click (outside popup) + overlay.addEventListener('click', (e) => { + if (e.target === overlay) { + overlay.remove(); + } + }); +} + +function createConfetti() { + const container = document.getElementById('confettiContainer'); + if (!container) return; + + const colors = ['#FFD700', '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD', '#98D8C8']; + const confettiCount = 100; + + for (let i = 0; i < confettiCount; i++) { + const confetti = document.createElement('div'); + confetti.className = 'confetti-piece'; + confetti.style.cssText = ` + position: absolute; + width: ${Math.random() * 10 + 5}px; + height: ${Math.random() * 10 + 5}px; + background: ${colors[Math.floor(Math.random() * colors.length)]}; + left: ${Math.random() * 100}%; + top: -20px; + border-radius: ${Math.random() > 0.5 ? '50%' : '2px'}; + animation: confetti-fall ${Math.random() * 3 + 2}s linear forwards; + animation-delay: ${Math.random() * 0.5}s; + transform: rotate(${Math.random() * 360}deg); + `; + container.appendChild(confetti); + } + + // Clean up confetti after animation + setTimeout(() => { + container.innerHTML = ''; + }, 4000); +} + +function escapeHtmlSimple(s) { + return String(s).replace( + /[&<>"']/g, + (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] + ); } export function onMessage(ev) { diff --git a/src/server-deno/public/js/render.js b/src/server-deno/public/js/render.js index 709128e..0de791e 100644 --- a/src/server-deno/public/js/render.js +++ b/src/server-deno/public/js/render.js @@ -84,11 +84,11 @@ export function renderRoom(room) { const year = t.year ?? '?'; const badgeStyle = badgeColorForYear(year); return ` -
-
${year}
-
-
${title}
-
${artist}
+
+
${year}
+
+
${title}
+
${artist}
`; diff --git a/src/server-deno/public/js/ui.js b/src/server-deno/public/js/ui.js index 151df1b..5837e6e 100644 --- a/src/server-deno/public/js/ui.js +++ b/src/server-deno/public/js/ui.js @@ -105,10 +105,19 @@ export function wireUi() { }); } + // Auto-uppercase room code input for better UX + if ($roomCode) { + wire($roomCode, "input", () => { + const pos = $roomCode.selectionStart; + $roomCode.value = $roomCode.value.toUpperCase(); + $roomCode.setSelectionRange(pos, pos); + }); + } + wire($createRoom, "click", () => sendMsg({ type: "create_room" })); wire($joinRoom, "click", () => { - const code = $roomCode.value.trim(); + const code = $roomCode.value.trim().toUpperCase(); if (code) sendMsg({ type: "join_room", roomId: code }); });