feat: Implement initial client-side UI rendering for game rooms, player dashboards, and track timelines, alongside core WebSocket server functionality.
All checks were successful
Build and Push Docker Image / docker (push) Successful in 13s

This commit is contained in:
2026-01-04 18:24:06 +01:00
parent a1f1b41987
commit 9373726347
8 changed files with 466 additions and 21 deletions

View File

@@ -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
*/

View File

@@ -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);
}
</style>
</head>
@@ -98,6 +123,10 @@
Code kopiert!
</div>
<!-- Reaction Container (for floating emojis) -->
<div id="reactionContainer"
class="fixed bottom-32 sm:bottom-20 left-0 right-0 h-48 sm:h-64 pointer-events-none z-40 overflow-hidden"></div>
<!-- Lobby Card -->
<div id="lobby"
class="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm p-4 md:p-6 space-y-4">
@@ -300,10 +329,15 @@
</div>
<!-- Volume (available to all players) -->
<div class="mt-3">
<div class="mt-3 flex flex-col sm:flex-row gap-4">
<label class="inline-flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
Lautstärke
<input id="volumeSlider" type="range" min="0" max="1" step="0.01" class="w-40 accent-indigo-600" />
🎵 Musik
<input id="volumeSlider" type="range" min="0" max="1" step="0.01" class="w-32 accent-indigo-600" />
</label>
<label class="inline-flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
🔊 SFX
<input id="sfxVolumeSlider" type="range" min="0" max="1" step="0.01" value="0.7"
class="w-32 accent-emerald-600" />
</label>
</div>
</div>
@@ -343,19 +377,23 @@
<h3 class="text-lg font-semibold">Position</h3>
<p class="text-sm text-slate-500 dark:text-slate-400 mb-2">Wähle die Position und klicke Platzieren.</p>
<div class="flex flex-wrap items-center gap-3">
<div id="placeArea" class="hidden flex flex-wrap items-stretch sm:items-center gap-2 w-full">
<div id="placeArea" class="hidden w-full">
<div class="flex flex-col sm:flex-row gap-2 w-full">
<select id="slotSelect"
class="h-10 rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800 px-3 flex-1 min-w-0"></select>
class="h-10 rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800 px-3 w-full sm:flex-1 sm:min-w-0"></select>
<div class="flex gap-2 w-full sm:w-auto">
<button id="placeBtn"
class="h-10 px-4 rounded-lg bg-slate-900 hover:bg-slate-700 text-white font-medium dark:bg-slate-700 dark:hover:bg-slate-600 shrink-0">
class="h-10 px-4 rounded-lg bg-slate-900 hover:bg-slate-700 text-white font-medium dark:bg-slate-700 dark:hover:bg-slate-600 flex-1 sm:flex-none">
Platzieren
</button>
<button id="placeWithTokensBtn"
class="h-10 px-3 rounded-lg bg-amber-500 hover:bg-amber-600 text-white font-medium flex items-center justify-center gap-1 whitespace-nowrap shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
class="h-10 px-3 rounded-lg bg-amber-500 hover:bg-amber-600 text-white font-medium flex items-center justify-center gap-1 whitespace-nowrap flex-1 sm:flex-none disabled:opacity-50 disabled:cursor-not-allowed"
title="Nutze 3 Tokens um die Karte automatisch richtig zu platzieren">
🪙 3 Tokens
</button>
</div>
</div>
</div>
<div id="nextArea" class="hidden w-full sm:w-auto">
<button id="nextBtn"
class="h-10 px-4 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white font-medium w-full sm:w-auto">
@@ -373,6 +411,27 @@
</div>
</div>
<!-- Reaction Bar -->
<div id="reactionBar" class="hidden mt-4 flex flex-wrap items-center justify-center gap-1 sm:gap-2">
<span
class="text-xs sm:text-sm text-slate-500 dark:text-slate-400 mr-1 sm:mr-2 w-full sm:w-auto text-center mb-1 sm:mb-0">Reaktionen:</span>
<button
class="reaction-btn text-xl sm:text-2xl p-1.5 sm:p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg active:bg-slate-200 dark:active:bg-slate-700"
data-emoji="😂" title="Lustig">😂</button>
<button
class="reaction-btn text-xl sm:text-2xl p-1.5 sm:p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg active:bg-slate-200 dark:active:bg-slate-700"
data-emoji="😱" title="Schock">😱</button>
<button
class="reaction-btn text-xl sm:text-2xl p-1.5 sm:p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg active:bg-slate-200 dark:active:bg-slate-700"
data-emoji="🔥" title="Feuer">🔥</button>
<button
class="reaction-btn text-xl sm:text-2xl p-1.5 sm:p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg active:bg-slate-200 dark:active:bg-slate-700"
data-emoji="👏" title="Applaus">👏</button>
<button
class="reaction-btn text-xl sm:text-2xl p-1.5 sm:p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg active:bg-slate-200 dark:active:bg-slate-700"
data-emoji="🎉" title="Party">🎉</button>
</div>
</div>
</div>

View File

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

View File

@@ -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,10 +220,15 @@ function showWinnerPopup(msg) {
${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">
<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);
@@ -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;
}
}
}

View File

@@ -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 ? `<span style="font-size: 0.7rem; color: white; background: rgba(0,0,0,0.5); padding: 2px 6px; border-radius: 4px; margin-top: 4px; white-space: nowrap;">${escapeHtml(playerName)}</span>` : '';
reaction.innerHTML = `
<span style="text-shadow: 0 2px 8px rgba(0,0,0,0.3);">${emoji}</span>
${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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]
);
}

View File

@@ -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) {

View File

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

View File

@@ -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", () => {