Files
hitstar/src/server-deno/public/js/handlers.js

383 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
$answerResult,
$guessArtist,
$guessTitle,
$npArtist,
$npTitle,
$npYear,
} from "./dom.js";
import { state } from "./state.js";
import { cacheLastRoomId, cacheSessionId, clearSessionId, sendMsg } from "./ws.js";
import { renderRoom } from "./render.js";
import { applySync, loadTrack, getSound, stopAudioPlayback } from "./audio.js";
import { playCorrect, playWrong, playWinner, playTurnStart, playCoinEarned } from "./sfx.js";
import { showReaction, celebrateCorrect } from "./reactions.js";
function updatePlayerIdFromRoom(r) {
try {
if (r?.players?.length === 1) {
const only = r.players[0];
if (only && only.id && only.id !== state.playerId) {
state.playerId = only.id;
try {
localStorage.setItem("playerId", only.id);
} catch { }
}
}
} catch { }
}
function shortName(id) {
if (!id) return "-";
const p = state.room?.players.find((x) => x.id === id);
return p ? p.name : id.slice(0, 4);
}
export function handleConnected(msg) {
state.playerId = msg.playerId;
try {
if (msg.playerId) localStorage.setItem("playerId", msg.playerId);
} catch { }
if (msg.sessionId) {
const existing = localStorage.getItem("sessionId");
if (!existing) cacheSessionId(msg.sessionId);
}
// lazy import to avoid cycle
import("./session.js").then(({ reusePlayerName, reconnectLastRoom }) => {
reusePlayerName();
reconnectLastRoom();
});
if (state.room) {
try {
updatePlayerIdFromRoom(state.room);
renderRoom(state.room);
} catch { }
}
}
export function handleRoomUpdate(msg) {
if (msg?.room?.id) cacheLastRoomId(msg.room.id);
const r = msg.room;
updatePlayerIdFromRoom(r);
renderRoom(r);
}
export function handlePlayTrack(msg) {
const t = msg.track;
state.lastTrack = t;
state.revealed = false;
$npTitle.textContent = "???";
$npArtist.textContent = "";
$npYear.textContent = "";
if ($guessTitle) $guessTitle.value = "";
if ($guessArtist) $guessArtist.value = "";
if ($answerResult) {
$answerResult.textContent = "";
$answerResult.className = "mt-1 text-sm";
}
// Load track with Howler, passing the filename for format detection
const sound = loadTrack(t.url, t.file);
const pf = document.getElementById("progressFill");
if (pf) pf.style.width = "0%";
const rd = document.getElementById("recordDisc");
if (rd) {
rd.classList.remove("spin-record");
rd.src = "/hitstar.png";
}
const { startAt, serverNow } = msg;
const now = Date.now();
const offsetMs = startAt - serverNow;
const localStart = now + offsetMs;
const delay = Math.max(0, localStart - now);
setTimeout(() => {
if (sound && sound === getSound() && !sound.playing()) {
sound.play();
const disc = document.getElementById("recordDisc");
if (disc) disc.classList.add("spin-record");
}
}, delay);
// Play turn start sound if it's the current player's turn
if (state.room?.state?.currentGuesser === state.playerId) {
playTurnStart();
}
if (state.room) renderRoom(state.room);
}
export function handleSync(msg) {
applySync(msg.startAt, msg.serverNow);
}
export function handleControl(msg) {
const { action, startAt, serverNow } = msg;
const sound = getSound();
if (!sound) return;
if (action === "pause") {
sound.pause();
const disc = document.getElementById("recordDisc");
if (disc) disc.classList.remove("spin-record");
sound.rate(1.0);
} else if (action === "play") {
if (startAt && serverNow) {
const now = Date.now();
const elapsed = (now - startAt) / 1000;
sound.seek(Math.max(0, elapsed));
}
sound.play();
const disc = document.getElementById("recordDisc");
if (disc) disc.classList.add("spin-record");
}
}
export function handleReveal(msg) {
const { result, track } = msg;
$npTitle.textContent = track.title || track.id || "Track";
$npArtist.textContent = track.artist ? ` ${track.artist}` : "";
$npYear.textContent = track.year ? ` (${track.year})` : "";
state.revealed = true;
const $rb = document.getElementById("revealBanner");
if ($rb) {
if (result.correct) {
$rb.textContent = "Richtig!";
$rb.className =
"inline-block rounded-md bg-emerald-600 text-white px-3 py-1 text-sm font-medium";
playCorrect();
celebrateCorrect(); // Auto 🎉 animation
} else {
$rb.textContent = "Falsch!";
$rb.className =
"inline-block rounded-md bg-rose-600 text-white px-3 py-1 text-sm font-medium";
playWrong();
}
}
// Note: placeArea visibility is now controlled by renderRoom() based on game phase
const rd = document.getElementById("recordDisc");
if (rd && track?.file) {
// Use track.file instead of track.id to include playlist folder prefix
const coverUrl = `/cover/${encodeURIComponent(track.file)}`;
const coverUrlWithTimestamp = `${coverUrl}?t=${Date.now()}`;
const img = new Image();
img.onload = () => {
rd.src = coverUrlWithTimestamp;
};
img.onerror = () => {
/* keep default logo */
};
img.src = coverUrlWithTimestamp;
}
}
export function handleGameEnded(msg) {
// Play winner fanfare
playWinner();
// 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>
<div class="mt-6 flex flex-col sm:flex-row gap-3 justify-center">
<button id="rematchBtn" class="px-6 py-3 bg-emerald-500 text-white font-bold rounded-xl hover:bg-emerald-600 transition-colors shadow-lg flex items-center justify-center gap-2">
🔄 Rematch
</button>
<button id="closeWinnerBtn" class="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>
</div>
`;
document.body.appendChild(overlay);
// Start confetti animation
createConfetti();
// Rematch button handler
document.getElementById('rematchBtn').addEventListener('click', () => {
stopAudioPlayback(); // Stop the music
sendMsg({ type: 'rematch' });
overlay.remove();
});
// 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) {
const msg = JSON.parse(ev.data);
switch (msg.type) {
case "resume_result": {
if (msg.ok) {
if (msg.playerId) {
state.playerId = msg.playerId;
try {
localStorage.setItem("playerId", msg.playerId);
} catch { }
}
const code =
msg.roomId || state.room?.id || localStorage.getItem("lastRoomId");
if (code) sendMsg({ type: "join_room", roomId: code });
if (state.room) {
try {
renderRoom(state.room);
} catch { }
}
// Restore player name after successful resume
import("./session.js").then(({ reusePlayerName }) => {
reusePlayerName();
});
} else {
// Resume failed - session expired or server restarted
// Clear stale session ID so next connect gets a fresh one
clearSessionId();
// Still try to join the last room
const code = state.room?.id || localStorage.getItem("lastRoomId");
if (code) sendMsg({ type: "join_room", roomId: code });
// Restore player name even on failed resume (new player, same name)
import("./session.js").then(({ reusePlayerName }) => {
reusePlayerName();
});
}
return;
}
case "connected":
return handleConnected(msg);
case "room_update":
return handleRoomUpdate(msg);
case "play_track":
return handlePlayTrack(msg);
case "sync":
return handleSync(msg);
case "control":
return handleControl(msg);
case "reveal":
return handleReveal(msg);
case "game_ended":
return handleGameEnded(msg);
case "answer_result": {
if ($answerResult) {
if (!msg.ok) {
$answerResult.textContent =
"⛔ Eingabe ungültig oder gerade nicht möglich";
$answerResult.className = "mt-1 text-sm text-rose-600";
} else {
const okBoth = !!(msg.correctTitle && msg.correctArtist);
const parts = [];
parts.push(msg.correctTitle ? "Titel ✓" : "Titel ✗");
parts.push(msg.correctArtist ? "Künstler ✓" : "Künstler ✗");
let coin = "";
if (msg.awarded) {
coin = " +1 Token";
playCoinEarned();
} else if (msg.alreadyAwarded) {
coin = " (bereits erhalten)";
}
$answerResult.textContent = `${parts.join(" · ")}${coin}`;
$answerResult.className = okBoth
? "mt-1 text-sm text-emerald-600"
: "mt-1 text-sm text-amber-600";
}
}
return;
}
default:
return;
case "reaction": {
// Show reaction from another player
showReaction(msg.emoji, msg.playerName);
return;
}
}
}