From 9373726347034bf3d4d35e1d5e9a0eb0426aeda9 Mon Sep 17 00:00:00 2001 From: Elmar Kresse Date: Sun, 4 Jan 2026 18:24:06 +0100 Subject: [PATCH] feat: Implement initial client-side UI rendering for game rooms, player dashboards, and track timelines, alongside core WebSocket server functionality. --- .../presentation/WebSocketServer.ts | 76 +++++++++ src/server-deno/public/index.html | 89 ++++++++-- src/server-deno/public/js/dom.js | 2 + src/server-deno/public/js/handlers.js | 45 ++++- src/server-deno/public/js/reactions.js | 93 +++++++++++ src/server-deno/public/js/render.js | 6 + src/server-deno/public/js/sfx.js | 157 ++++++++++++++++++ src/server-deno/public/js/ui.js | 19 +++ 8 files changed, 466 insertions(+), 21 deletions(-) create mode 100644 src/server-deno/public/js/reactions.js create mode 100644 src/server-deno/public/js/sfx.js diff --git a/src/server-deno/presentation/WebSocketServer.ts b/src/server-deno/presentation/WebSocketServer.ts index 6bb3e02..0b48663 100644 --- a/src/server-deno/presentation/WebSocketServer.ts +++ b/src/server-deno/presentation/WebSocketServer.ts @@ -169,6 +169,14 @@ export class WebSocketServer { this.handleKickPlayer(msg, player); break; + case 'rematch': + this.handleRematch(player); + break; + + case 'reaction': + this.handleReaction(msg, player); + break; + default: logger.debug(`Unknown message type: ${msg.type}`); } @@ -635,6 +643,74 @@ export class WebSocketServer { } } + /** + * Handle rematch request - reset game to lobby with same settings + */ + private handleRematch(player: PlayerModel): void { + if (!player.roomId) return; + + const room = this.roomService.getRoom(player.roomId); + if (!room) return; + + // Only host can initiate rematch + if (room.hostId !== player.id) { + logger.debug(`Non-host ${player.id} tried to initiate rematch`); + return; + } + + // Stop any sync timers + this.stopSyncTimer(room); + + // Reset game state to lobby + room.state.status = GameStatus.LOBBY; + room.state.phase = GamePhase.GUESS; + room.state.currentGuesser = null; + room.state.currentTrack = null; + room.state.lastResult = null; + room.state.trackStartAt = null; + room.state.paused = false; + room.state.pausedPosSec = 0; + room.state.titleArtistAwardedThisRound = {}; + + // Clear all player timelines, tokens, and ready states + for (const playerId of room.players.keys()) { + room.state.timeline[playerId] = []; + room.state.tokens[playerId] = 0; + room.state.ready[playerId] = false; + } + + // Keep playlist and goal settings intact + // room.state.playlist stays the same + // room.state.goal stays the same + + // Reset deck (will be refilled when game starts again) + room.deck = []; + room.discard = []; + + logger.info(`Room ${room.id}: Rematch initiated by host ${player.id}`); + this.broadcastRoomUpdate(room); + } + + /** + * Handle reaction - broadcast emoji to all players in room + */ + private handleReaction(msg: any, player: PlayerModel): void { + if (!player.roomId) return; + + const room = this.roomService.getRoom(player.roomId); + if (!room) return; + + const emoji = msg.emoji; + if (!emoji || typeof emoji !== 'string') return; + + // Broadcast reaction to all players + this.broadcast(room, 'reaction', { + emoji, + playerId: player.id, + playerName: player.name, + }); + } + /** * Draw next track */ diff --git a/src/server-deno/public/index.html b/src/server-deno/public/index.html index 885a5d1..82d979a 100644 --- a/src/server-deno/public/index.html +++ b/src/server-deno/public/index.html @@ -81,6 +81,31 @@ .animate-bounce-slow { animation: bounce-slow 1.5s ease-in-out infinite; } + + /* Live reactions animation */ + @keyframes reaction-rise { + 0% { + transform: translateX(-50%) translateY(0) scale(1); + opacity: 1; + } + + 100% { + transform: translateX(-50%) translateY(-200px) scale(1.2); + opacity: 0; + } + } + + .reaction-btn { + transition: transform 0.15s ease; + } + + .reaction-btn:hover { + transform: scale(1.3); + } + + .reaction-btn:active { + transform: scale(0.9); + } @@ -98,6 +123,10 @@ Code kopiert! + +
+
@@ -300,10 +329,15 @@
-
+
+
@@ -343,18 +377,22 @@

Position

Wรคhle die Position und klicke Platzieren.

-
diff --git a/src/server-deno/public/js/dom.js b/src/server-deno/public/js/dom.js index 778e0af..675d56f 100644 --- a/src/server-deno/public/js/dom.js +++ b/src/server-deno/public/js/dom.js @@ -47,6 +47,8 @@ export const $playlistSection = el("playlistSection"); export const $playlistSelect = el("playlistSelect"); export const $currentPlaylist = el("currentPlaylist"); export const $playlistInfo = el("playlistInfo"); +// SFX controls +export const $sfxVolumeSlider = el("sfxVolumeSlider"); export function showLobby() { $lobby.classList.remove("hidden"); diff --git a/src/server-deno/public/js/handlers.js b/src/server-deno/public/js/handlers.js index a695d22..76451d5 100644 --- a/src/server-deno/public/js/handlers.js +++ b/src/server-deno/public/js/handlers.js @@ -9,7 +9,9 @@ import { import { state } from "./state.js"; import { cacheLastRoomId, cacheSessionId, clearSessionId, sendMsg } from "./ws.js"; import { renderRoom } from "./render.js"; -import { applySync, loadTrack, getSound } from "./audio.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 { @@ -101,6 +103,11 @@ export function handlePlayTrack(msg) { } }, 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); } @@ -143,10 +150,13 @@ export function handleReveal(msg) { $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 @@ -167,6 +177,8 @@ export function handleReveal(msg) { } export function handleGameEnded(msg) { + // Play winner fanfare + playWinner(); // Create and show the winner popup with confetti showWinnerPopup(msg); } @@ -208,9 +220,14 @@ function showWinnerPopup(msg) { ${timelineHtml} - +
+ + +
`; @@ -219,6 +236,13 @@ function showWinnerPopup(msg) { // 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(); @@ -333,8 +357,12 @@ export function onMessage(ev) { parts.push(msg.correctTitle ? "Titel โœ“" : "Titel โœ—"); parts.push(msg.correctArtist ? "Kรผnstler โœ“" : "Kรผnstler โœ—"); let coin = ""; - if (msg.awarded) coin = " +1 Token"; - else if (msg.alreadyAwarded) coin = " (bereits erhalten)"; + 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" @@ -345,5 +373,10 @@ export function onMessage(ev) { } default: return; + case "reaction": { + // Show reaction from another player + showReaction(msg.emoji, msg.playerName); + return; + } } } diff --git a/src/server-deno/public/js/reactions.js b/src/server-deno/public/js/reactions.js new file mode 100644 index 0000000..500db5b --- /dev/null +++ b/src/server-deno/public/js/reactions.js @@ -0,0 +1,93 @@ +/** + * Live Reactions Module for Hitstar Party Mode + * Real-time emoji reactions visible to all players + */ + +import { sendMsg } from './ws.js'; +import { state } from './state.js'; + +// Rate limiting +let lastReactionTime = 0; +const REACTION_COOLDOWN_MS = 500; + +// Available reactions +export const REACTIONS = ['๐Ÿ˜‚', '๐Ÿ˜ฑ', '๐Ÿ”ฅ', '๐Ÿ‘', '๐ŸŽ‰']; + +/** + * Send a reaction to all players + * @param {string} emoji - The emoji to send + */ +export function sendReaction(emoji) { + const now = Date.now(); + if (now - lastReactionTime < REACTION_COOLDOWN_MS) { + return; // Rate limited + } + lastReactionTime = now; + sendMsg({ type: 'reaction', emoji }); +} + +/** + * Display a floating reaction animation + * @param {string} emoji - The emoji to display + * @param {string} playerName - Name of the player who reacted + */ +export function showReaction(emoji, playerName) { + const container = document.getElementById('reactionContainer'); + if (!container) return; + + // Create reaction element + const reaction = document.createElement('div'); + reaction.className = 'reaction-float'; + + // Random horizontal position (20-80% of container width) + const xPos = 20 + Math.random() * 60; + + reaction.style.cssText = ` + position: absolute; + bottom: 0; + left: ${xPos}%; + transform: translateX(-50%); + font-size: 2.5rem; + pointer-events: none; + animation: reaction-rise 2s ease-out forwards; + display: flex; + flex-direction: column; + align-items: center; + z-index: 100; + `; + + // Show player name if not self + const isMe = state.room?.players?.some(p => p.id === state.playerId && p.name === playerName); + const nameLabel = playerName && !isMe ? `${escapeHtml(playerName)}` : ''; + + reaction.innerHTML = ` + ${emoji} + ${nameLabel} + `; + + container.appendChild(reaction); + + // Remove after animation + setTimeout(() => { + reaction.remove(); + }, 2000); +} + +/** + * Trigger celebration reactions (auto ๐ŸŽ‰) + */ +export function celebrateCorrect() { + // Show multiple party emojis + for (let i = 0; i < 3; i++) { + setTimeout(() => { + showReaction('๐ŸŽ‰', ''); + }, i * 150); + } +} + +function escapeHtml(s) { + return String(s).replace( + /[&<>"']/g, + (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] + ); +} diff --git a/src/server-deno/public/js/render.js b/src/server-deno/public/js/render.js index 28e3e99..1513bcc 100644 --- a/src/server-deno/public/js/render.js +++ b/src/server-deno/public/js/render.js @@ -271,6 +271,12 @@ export function renderRoom(room) { $answerResult.textContent = ''; $answerResult.className = 'mt-1 text-sm'; } + + // Show reaction bar during gameplay + const $reactionBar = document.getElementById('reactionBar'); + if ($reactionBar) { + $reactionBar.classList.toggle('hidden', room.state.status !== 'playing'); + } } export function shortName(id) { diff --git a/src/server-deno/public/js/sfx.js b/src/server-deno/public/js/sfx.js new file mode 100644 index 0000000..56953f5 --- /dev/null +++ b/src/server-deno/public/js/sfx.js @@ -0,0 +1,157 @@ +/** + * Sound Effects Module for Hitstar Party Mode + * Uses Howler.js for audio playback with synthesized tones + */ + +// SFX volume (0-1), separate from music +let sfxVolume = 0.7; + +// Audio context for generating tones +let audioCtx = null; + +function getAudioContext() { + if (!audioCtx) { + audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + } + return audioCtx; +} + +/** + * Play a synthesized tone + * @param {number} frequency - Frequency in Hz + * @param {number} duration - Duration in seconds + * @param {string} type - Oscillator type: 'sine', 'square', 'sawtooth', 'triangle' + * @param {number} [volume] - Volume multiplier (0-1) + */ +function playTone(frequency, duration, type = 'sine', volume = 1) { + try { + const ctx = getAudioContext(); + if (ctx.state === 'suspended') { + ctx.resume(); + } + + const oscillator = ctx.createOscillator(); + const gainNode = ctx.createGain(); + + oscillator.type = type; + oscillator.frequency.setValueAtTime(frequency, ctx.currentTime); + + // Apply volume with fade out + const effectiveVolume = sfxVolume * volume * 0.3; // Keep SFX quieter than music + gainNode.gain.setValueAtTime(effectiveVolume, ctx.currentTime); + gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration); + + oscillator.connect(gainNode); + gainNode.connect(ctx.destination); + + oscillator.start(ctx.currentTime); + oscillator.stop(ctx.currentTime + duration); + } catch (e) { + console.warn('SFX playback failed:', e); + } +} + +/** + * Play a sequence of tones + * @param {Array<{freq: number, dur: number, delay: number, type?: string, vol?: number}>} notes + */ +function playSequence(notes) { + notes.forEach(note => { + setTimeout(() => { + playTone(note.freq, note.dur, note.type || 'sine', note.vol || 1); + }, note.delay * 1000); + }); +} + +// ============= Sound Effect Functions ============= + +/** + * Play success sound - rising two-tone chime + */ +export function playCorrect() { + playSequence([ + { freq: 523.25, dur: 0.15, delay: 0, type: 'sine' }, // C5 + { freq: 659.25, dur: 0.25, delay: 0.1, type: 'sine' }, // E5 + ]); +} + +/** + * Play error sound - descending buzz + */ +export function playWrong() { + playSequence([ + { freq: 220, dur: 0.15, delay: 0, type: 'sawtooth', vol: 0.5 }, // A3 + { freq: 175, dur: 0.25, delay: 0.12, type: 'sawtooth', vol: 0.5 }, // F3 + ]); +} + +/** + * Play winner fanfare + */ +export function playWinner() { + playSequence([ + { freq: 523.25, dur: 0.12, delay: 0, type: 'square', vol: 0.6 }, // C5 + { freq: 659.25, dur: 0.12, delay: 0.12, type: 'square', vol: 0.6 }, // E5 + { freq: 783.99, dur: 0.12, delay: 0.24, type: 'square', vol: 0.6 }, // G5 + { freq: 1046.50, dur: 0.4, delay: 0.36, type: 'sine', vol: 0.8 }, // C6 (victory note) + ]); +} + +/** + * Play turn start notification ping + */ +export function playTurnStart() { + playSequence([ + { freq: 880, dur: 0.08, delay: 0, type: 'sine', vol: 0.4 }, // A5 + { freq: 1108.73, dur: 0.15, delay: 0.08, type: 'sine', vol: 0.6 }, // C#6 + ]); +} + +/** + * Play coin/token earned sound + */ +export function playCoinEarned() { + playSequence([ + { freq: 1318.51, dur: 0.06, delay: 0, type: 'sine', vol: 0.5 }, // E6 + { freq: 1567.98, dur: 0.06, delay: 0.05, type: 'sine', vol: 0.6 }, // G6 + { freq: 2093, dur: 0.12, delay: 0.1, type: 'sine', vol: 0.7 }, // C7 + ]); +} + +/** + * Play countdown tick sound + */ +export function playTick() { + playTone(800, 0.05, 'sine', 0.3); +} + +/** + * Set SFX volume + * @param {number} volume - Volume level (0-1) + */ +export function setSfxVolume(volume) { + sfxVolume = Math.max(0, Math.min(1, volume)); +} + +/** + * Get current SFX volume + * @returns {number} Current volume (0-1) + */ +export function getSfxVolume() { + return sfxVolume; +} + +/** + * Initialize SFX system (call on first user interaction) + */ +export function initSfx() { + // Pre-warm audio context on user interaction + try { + const ctx = getAudioContext(); + if (ctx.state === 'suspended') { + ctx.resume(); + } + } catch (e) { + console.warn('SFX initialization failed:', e); + } +} diff --git a/src/server-deno/public/js/ui.js b/src/server-deno/public/js/ui.js index 6da7bed..a1ad833 100644 --- a/src/server-deno/public/js/ui.js +++ b/src/server-deno/public/js/ui.js @@ -22,14 +22,26 @@ import { $playBtn, $volumeSlider, $saveName, + $sfxVolumeSlider, } from "./dom.js"; import { state } from "./state.js"; import { initAudioUI, stopAudioPlayback } from "./audio.js"; import { sendMsg } from "./ws.js"; import { showToast, wire } from "./utils.js"; +import { setSfxVolume, initSfx } from "./sfx.js"; +import { sendReaction } from "./reactions.js"; export function wireUi() { initAudioUI(); + initSfx(); // Initialize sound effects system + + // Wire reaction buttons + document.querySelectorAll('.reaction-btn').forEach((btn) => { + btn.addEventListener('click', () => { + const emoji = btn.getAttribute('data-emoji'); + if (emoji) sendReaction(emoji); + }); + }); // --- Name autosave helpers let nameDebounce; @@ -201,6 +213,13 @@ export function wireUi() { // Volume slider is now handled in audio.js initAudioUI() + // SFX Volume slider + if ($sfxVolumeSlider) { + wire($sfxVolumeSlider, "input", (e) => { + setSfxVolume(parseFloat(e.target.value)); + }); + } + if ($copyRoomCode) { $copyRoomCode.style.display = "inline-block"; wire($copyRoomCode, "click", () => {