feat: Implement client-side game logic and UI with WebSocket message handlers, including a winner popup and confetti animation.
All checks were successful
Build and Push Docker Image / docker (push) Successful in 9s

This commit is contained in:
2026-01-04 12:14:53 +01:00
parent c9be49d988
commit 8ca744cd5b
5 changed files with 181 additions and 9 deletions

View File

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

View File

@@ -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;
}
</style>
</head>

View File

@@ -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 => `
<div class="flex items-start gap-3 bg-white/10 rounded-lg px-3 py-2 w-full">
<div class="flex-shrink-0 font-bold tabular-nums bg-indigo-600 text-white rounded-md px-2 py-1 min-w-[48px] text-center text-sm">${t.year ?? '?'}</div>
<div class="flex-1 min-w-0 leading-tight text-left overflow-hidden">
<div class="font-semibold text-white text-sm break-words" style="word-break: break-word; hyphens: auto;">${escapeHtmlSimple(t.title || 'Unknown')}</div>
<div class="text-xs text-white/70 break-words" style="word-break: break-word;">${escapeHtmlSimple(t.artist || '')}</div>
</div>
</div>
`).join('')
: '<p class="text-white/60 text-sm">Keine Karten</p>';
overlay.innerHTML = `
<div id="confettiContainer" class="fixed inset-0 pointer-events-none overflow-hidden"></div>
<div class="relative bg-gradient-to-br from-indigo-600 via-purple-600 to-pink-600 rounded-2xl shadow-2xl max-w-md w-full p-6 text-center animate-popup">
<div class="absolute -top-6 left-1/2 -translate-x-1/2 text-6xl animate-bounce-slow">🏆</div>
<h2 class="text-3xl font-bold text-white mt-4 mb-2">Gewinner!</h2>
<p class="text-4xl font-extrabold text-yellow-300 mb-2">${escapeHtmlSimple(winnerName)}</p>
<p class="text-lg text-white/90 mb-4">Score: <span class="font-bold text-2xl">${score}</span> Karten</p>
<div class="bg-black/20 rounded-xl p-3 max-h-60 overflow-y-auto">
<h3 class="text-sm font-semibold text-white/80 mb-2">Zeitleiste</h3>
<div class="flex flex-col gap-2">
${timelineHtml}
</div>
</div>
<button id="closeWinnerBtn" class="mt-6 px-6 py-3 bg-white text-indigo-600 font-bold rounded-xl hover:bg-indigo-100 transition-colors shadow-lg">
Schließen
</button>
</div>
`;
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]
);
}
export function onMessage(ev) {

View File

@@ -84,11 +84,11 @@ export function renderRoom(room) {
const year = t.year ?? '?';
const badgeStyle = badgeColorForYear(year);
return `
<div class="flex items-center gap-2 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2 bg-white text-slate-900 dark:bg-slate-800 dark:text-slate-100 shadow-sm" title="${title}${artist ? ' — ' + artist : ''} (${year})">
<div class="font-bold tabular-nums text-white rounded-md px-2 py-0.5 min-w-[3ch] text-center" style="${badgeStyle}">${year}</div>
<div class="leading-tight">
<div class="font-semibold">${title}</div>
<div class="text-sm text-slate-600 dark:text-slate-300">${artist}</div>
<div class="flex items-start gap-3 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2 bg-white text-slate-900 dark:bg-slate-800 dark:text-slate-100 shadow-sm w-full" title="${title}${artist ? ' — ' + artist : ''} (${year})">
<div class="flex-shrink-0 font-bold tabular-nums text-white rounded-md px-2 py-1 min-w-[48px] text-center" style="${badgeStyle}">${year}</div>
<div class="flex-1 min-w-0 leading-tight overflow-hidden">
<div class="font-semibold break-words" style="word-break: break-word; hyphens: auto;">${title}</div>
<div class="text-sm text-slate-600 dark:text-slate-300 break-words" style="word-break: break-word;">${artist}</div>
</div>
</div>
`;

View File

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