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
All checks were successful
Build and Push Docker Image / docker (push) Successful in 13s
This commit is contained in:
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
93
src/server-deno/public/js/reactions.js
Normal file
93
src/server-deno/public/js/reactions.js
Normal 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
157
src/server-deno/public/js/sfx.js
Normal file
157
src/server-deno/public/js/sfx.js
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user