550 lines
25 KiB
JavaScript
550 lines
25 KiB
JavaScript
import "./styles.sass";
|
|
import {SocketContext} from "@/common/contexts/SocketContext";
|
|
import {useContext, useState, useEffect, useRef, useCallback} from "react";
|
|
import MusicSlider from "@/pages/Game/components/MusicSlider";
|
|
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
|
import {
|
|
faMessage,
|
|
faMusic,
|
|
faHeadphones,
|
|
faClock,
|
|
faCrown,
|
|
faPaperPlane,
|
|
faCheckCircle,
|
|
faVolumeUp,
|
|
faStepForward
|
|
} from "@fortawesome/free-solid-svg-icons";
|
|
import { fetchPlaylistSongs } from "@/services/youtubeService.js";
|
|
import YouTubePlayer from "../../components/YouTubePlayer/YouTubePlayer";
|
|
|
|
export const Game = () => {
|
|
const {send, on, socket, connected, connect} = useContext(SocketContext);
|
|
|
|
useEffect(() => {
|
|
if (!connected) {
|
|
connect();
|
|
}
|
|
}, [connected, connect]);
|
|
|
|
const [role, setRole] = useState(null);
|
|
const [round, setRound] = useState(1);
|
|
const [phase, setPhase] = useState("waiting");
|
|
const [timeLeft, setTimeLeft] = useState(30);
|
|
const [frequency, setFrequency] = useState(440);
|
|
const [currentSong, setCurrentSong] = useState(null);
|
|
const [songOptions, setSongOptions] = useState([]);
|
|
const [scores, setScores] = useState({});
|
|
const [selectedSong, setSelectedSong] = useState(null);
|
|
const [guessResult, setGuessResult] = useState(null);
|
|
const [isHost, setIsHost] = useState(false);
|
|
const [hasGuessed, setHasGuessed] = useState(false);
|
|
|
|
const [messages, setMessages] = useState([]);
|
|
const [inputValue, setInputValue] = useState("");
|
|
const [connectedUsers, setConnectedUsers] = useState([]);
|
|
const [username, setUsername] = useState("");
|
|
const messageEndRef = useRef(null);
|
|
const timerIntervalRef = useRef(null);
|
|
|
|
const [allSongs, setAllSongs] = useState([]);
|
|
const [songsLoading, setSongsLoading] = useState(false);
|
|
const [composerIsPlaying, setComposerIsPlaying] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!connected) return;
|
|
|
|
|
|
const eventHandlers = {
|
|
"roles-assigned": (roles) => {
|
|
const myRole = roles[socket?.id];
|
|
if (myRole) {
|
|
setRole(myRole);
|
|
setMessages(prev => [...prev, {
|
|
system: true,
|
|
text: myRole === "composer"
|
|
? "Du bist der Komponist! Spiele den Song mit dem Tonregler."
|
|
: "Du bist ein Rater! Höre die Frequenzen und versuche, den Song zu erkennen."
|
|
}]);
|
|
}
|
|
},
|
|
"song-selected": setCurrentSong,
|
|
"round-started": (data) => {
|
|
setRound(data.round);
|
|
setPhase("composing");
|
|
setTimeLeft(data.timeRemaining);
|
|
},
|
|
"guessing-phase-started": (data) => {
|
|
console.log("Guessing phase started:", data);
|
|
setPhase("guessing");
|
|
setTimeLeft(data.timeRemaining);
|
|
setSongOptions(data.songOptions || []);
|
|
},
|
|
"guess-result": (result) => {
|
|
setGuessResult(result.isCorrect);
|
|
setCurrentSong(result.correctSong);
|
|
},
|
|
"round-results": (results) => {
|
|
setPhase("results");
|
|
const scoresWithNames = {};
|
|
Object.entries(results.scores).forEach(([userId, score]) => {
|
|
const user = connectedUsers.find(u => u.id === userId);
|
|
scoresWithNames[userId] = {
|
|
score: score,
|
|
name: user?.name || results.playerNames?.[userId] || "Player"
|
|
};
|
|
});
|
|
|
|
setScores(scoresWithNames);
|
|
|
|
if (!currentSong) {
|
|
setCurrentSong(results.selectedSong);
|
|
}
|
|
},
|
|
"room-users-update": (users) => {
|
|
setConnectedUsers(users);
|
|
const currentUser = users.find(u => u.id === socket?.id);
|
|
if (currentUser) setUsername(currentUser.name);
|
|
},
|
|
"host-status": (status) => setIsHost(status.isHost),
|
|
"chat-message": (msg) => setMessages(prev => [...prev, msg]),
|
|
"user-connected": (userData) => {
|
|
setMessages(prev => [...prev, {
|
|
system: true,
|
|
text: `${userData.name} ist beigetreten`
|
|
}]);
|
|
setConnectedUsers(prev => [...prev, userData]);
|
|
},
|
|
"user-disconnected": (userId) => {
|
|
setConnectedUsers(prev => {
|
|
const user = prev.find(u => u.id === userId);
|
|
if (user) {
|
|
setMessages(prevMsgs => [...prevMsgs, {
|
|
system: true,
|
|
text: `${user.name} hat den Raum verlassen`
|
|
}]);
|
|
}
|
|
return prev.filter(u => u.id !== userId);
|
|
});
|
|
},
|
|
"frequency-update": (data) => {
|
|
if (phase === "composing") {
|
|
console.log("Received frequency update:", data.frequency);
|
|
setFrequency(data.frequency);
|
|
setComposerIsPlaying(data.isPlaying); // Make sure isPlaying is handled
|
|
}
|
|
},
|
|
"phase-changed": (data) => {
|
|
console.log("Phase changed:", data);
|
|
setPhase(data.phase);
|
|
if (data.timeRemaining) {
|
|
setTimeLeft(data.timeRemaining);
|
|
}
|
|
},
|
|
"chat-message-confirmation": (msg) => {
|
|
setMessages(prev => prev.map(prevMsg => {
|
|
if (!prevMsg.system && prevMsg.text === msg.text && prevMsg.sender !== msg.sender) {
|
|
console.log(`Updating message sender from "${prevMsg.sender}" to "${msg.sender}"`);
|
|
return { ...prevMsg, sender: msg.sender };
|
|
}
|
|
return prevMsg;
|
|
}));
|
|
},
|
|
};
|
|
|
|
const cleanupFunctions = Object.entries(eventHandlers).map(
|
|
([event, handler]) => on(event, handler)
|
|
);
|
|
|
|
if (connected) {
|
|
send("get-room-users");
|
|
send("check-host-status");
|
|
}
|
|
|
|
return () => cleanupFunctions.forEach(cleanup => cleanup());
|
|
}, [socket, on, send, role, currentSong, phase, connected, connectedUsers]);
|
|
|
|
useEffect(() => {
|
|
if (timerIntervalRef.current) clearInterval(timerIntervalRef.current);
|
|
|
|
if (phase !== "waiting" && phase !== "results") {
|
|
timerIntervalRef.current = setInterval(() => {
|
|
setTimeLeft(prev => Math.max(0, prev - 1));
|
|
}, 1000);
|
|
}
|
|
|
|
return () => {
|
|
if (timerIntervalRef.current) clearInterval(timerIntervalRef.current);
|
|
};
|
|
}, [phase]);
|
|
|
|
useEffect(() => {
|
|
messageEndRef.current?.scrollIntoView({behavior: "smooth"});
|
|
}, [messages]);
|
|
|
|
useEffect(() => {
|
|
if (!connected || !socket) return;
|
|
|
|
const loadSongs = async () => {
|
|
try {
|
|
setSongsLoading(true);
|
|
console.log("Fetching songs with active socket:", socket?.id);
|
|
const songs = await fetchPlaylistSongs(socket);
|
|
console.log(`Successfully loaded ${songs.length} songs`);
|
|
setAllSongs(songs);
|
|
} catch (error) {
|
|
console.error("Error loading songs:", error);
|
|
} finally {
|
|
setSongsLoading(false);
|
|
}
|
|
};
|
|
|
|
loadSongs();
|
|
}, [socket, connected]);
|
|
|
|
useEffect(() => {
|
|
if (!allSongs.length || !currentSong || !currentSong.id) return;
|
|
|
|
const enhancedSong = allSongs.find(song => song.id === currentSong.id);
|
|
if (enhancedSong && enhancedSong !== currentSong) {
|
|
setCurrentSong(enhancedSong);
|
|
}
|
|
}, [allSongs, currentSong]);
|
|
|
|
useEffect(() => {
|
|
if (!allSongs.length || !songOptions.length) return;
|
|
|
|
const enhancedOptions = songOptions.map(option => {
|
|
if (option.id && !option.title) {
|
|
return allSongs.find(song => song.id === option.id) || option;
|
|
}
|
|
return option;
|
|
});
|
|
|
|
if (JSON.stringify(enhancedOptions) !== JSON.stringify(songOptions)) {
|
|
setSongOptions(enhancedOptions);
|
|
}
|
|
}, [allSongs, songOptions]);
|
|
|
|
const handleFrequencyChange = useCallback((newFrequency, isPlaying) => {
|
|
setFrequency(newFrequency);
|
|
setComposerIsPlaying(isPlaying);
|
|
if (role === "composer") {
|
|
send("submit-frequency", { frequency: newFrequency, isPlaying });
|
|
}
|
|
}, [role, send]);
|
|
|
|
const handleSendMessage = useCallback(() => {
|
|
if (inputValue.trim()) {
|
|
const currentUser = connectedUsers.find(u => u.id === socket?.id);
|
|
const senderName = currentUser?.name || username || "Player";
|
|
|
|
const messageData = { text: inputValue, sender: senderName };
|
|
send("send-message", messageData);
|
|
setMessages(prev => [...prev, messageData]);
|
|
setInputValue("");
|
|
}
|
|
}, [inputValue, send, socket?.id, connectedUsers, username]);
|
|
|
|
const handleSongSelect = useCallback((song) => {
|
|
setSelectedSong(song);
|
|
}, []);
|
|
|
|
const handleSubmitGuess = useCallback(() => {
|
|
if (!selectedSong || phase !== 'guessing') return;
|
|
|
|
console.log("Submitting guess:", selectedSong.id);
|
|
send("submit-guess", { songId: selectedSong.id });
|
|
|
|
setMessages(prev => [...prev, {
|
|
system: true,
|
|
text: `Du hast "${selectedSong.title}" von ${selectedSong.artist} gewählt.`
|
|
}]);
|
|
|
|
setHasGuessed(true);
|
|
}, [selectedSong, send, phase]);
|
|
|
|
const handleNextRound = useCallback(() => {
|
|
send("next-round");
|
|
setSelectedSong(null);
|
|
setGuessResult(null);
|
|
setTimeLeft(0);
|
|
}, [send]);
|
|
|
|
const handlePlayerReady = useCallback(() => {
|
|
console.log("Player ready");
|
|
}, []);
|
|
|
|
const renderPhaseContent = () => {
|
|
switch (phase) {
|
|
case "waiting":
|
|
return (
|
|
<div className="waiting-phase">
|
|
<h3>Warten auf Spielstart...</h3>
|
|
</div>
|
|
);
|
|
|
|
case "composing":
|
|
return (
|
|
<div className="composing-phase">
|
|
<div className="phase-header">
|
|
<h3>Runde {round}: {role === "composer" ? "Spielen" : "Zuhören"}</h3>
|
|
<div className="timer">
|
|
<FontAwesomeIcon icon={faClock} /> {timeLeft}s
|
|
</div>
|
|
</div>
|
|
|
|
{role === "composer" && currentSong && (
|
|
<div className="song-display">
|
|
<div className="song-card">
|
|
<img src={currentSong.coverUrl} alt={currentSong.title} />
|
|
<div className="song-info">
|
|
<div className="song-names">{currentSong.title}</div>
|
|
<div className="song-description">von {currentSong.artist}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="song-player-container">
|
|
<YouTubePlayer
|
|
videoId={currentSong.youtubeId}
|
|
autoplay={true}
|
|
startTime={currentSong.refrainTime || 45}
|
|
onReady={handlePlayerReady}
|
|
className="song-embedded-player"
|
|
/>
|
|
</div>
|
|
|
|
<div className="music-controls">
|
|
<p className="instruction">Use the player above to listen, and the tone slider to play!</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{role === "guesser" && (
|
|
<div className="listening-display">
|
|
<div className="listening-icon">
|
|
<FontAwesomeIcon icon={faHeadphones} size="4x" />
|
|
</div>
|
|
<p className="instruction">Höre genau zu und versuche, den Song zu erkennen!</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
case "guessing":
|
|
return (
|
|
<div className="guessing-phase">
|
|
<div className="phase-header">
|
|
<h3>Runde {round}: Song erraten</h3>
|
|
<div className="timer">
|
|
<FontAwesomeIcon icon={faClock} /> {timeLeft}s
|
|
</div>
|
|
</div>
|
|
|
|
{role === "composer" ? (
|
|
<div className="waiting-for-guessers">
|
|
<p>Die Rater versuchen nun, deinen Song zu erraten...</p>
|
|
</div>
|
|
) : (
|
|
<div className="song-selection">
|
|
<p className="instruction">Welchen Song hat der Komponist gespielt?</p>
|
|
|
|
{songOptions.length === 0 ? (
|
|
<div className="loading-songs">
|
|
<p>Lade Songoptionen...</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="song-grid">
|
|
{songOptions.map(song => (
|
|
<div
|
|
key={song.id}
|
|
className={`song-option ${selectedSong?.id === song.id ? 'selected' : ''} ${hasGuessed ? 'disabled' : ''}`}
|
|
onClick={() => !hasGuessed && handleSongSelect(song)}
|
|
>
|
|
<div className="song-image">
|
|
<img src={song.coverUrl} alt={song.title} />
|
|
{selectedSong?.id === song.id && (
|
|
<div className="selection-indicator">
|
|
<FontAwesomeIcon icon={faCheckCircle} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="song-details">
|
|
<div className="song-title">{song.title}</div>
|
|
<div className="song-artist">{song.artist}</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="guess-actions">
|
|
{hasGuessed ? (
|
|
<p className="guess-submitted">Deine Antwort wurde eingereicht!</p>
|
|
) : (
|
|
<button
|
|
className={`submit-guess-button ${!selectedSong ? 'disabled' : ''}`}
|
|
onClick={handleSubmitGuess}
|
|
disabled={!selectedSong}
|
|
>
|
|
<FontAwesomeIcon icon={faPaperPlane} />
|
|
Antwort einreichen
|
|
</button>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
case "results":
|
|
return (
|
|
<div className="results-phase">
|
|
<h3>Runde {round}: Ergebnisse</h3>
|
|
|
|
<div className="round-results">
|
|
{role === "composer" ? (
|
|
<div className="composer-results">
|
|
<p>Die Rater haben versucht, deinen Song zu erraten.</p>
|
|
</div>
|
|
) : (
|
|
<div className="guesser-results">
|
|
{currentSong && (
|
|
<div className="correct-song">
|
|
<p>Der richtige Song war:</p>
|
|
<div className="song-card highlight">
|
|
<img src={currentSong.coverUrl} alt={currentSong.title} />
|
|
<div className="song-info">
|
|
<div className="song-names">{currentSong.title}</div>
|
|
<div className="song-description">von {currentSong.artist}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{guessResult !== null && (
|
|
<div className={`guess-result ${guessResult ? 'correct' : 'incorrect'}`}>
|
|
{guessResult
|
|
? 'Richtig! Du erhälst 10 Punkte.'
|
|
: 'Leider falsch. Kein Punkt.'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="scoreboard">
|
|
<h4>Punktestand</h4>
|
|
<div className="scores">
|
|
{Object.entries(scores).map(([userId, scoreData]) => {
|
|
const isCurrentUser = userId === socket?.id;
|
|
const isHost = connectedUsers.find(u => u.id === "user1")?.id === userId;
|
|
const playerName = typeof scoreData === 'object' ?
|
|
scoreData.name :
|
|
(connectedUsers.find(u => u.id === userId)?.name || "Player");
|
|
const playerScore = typeof scoreData === 'object' ? scoreData.score : scoreData;
|
|
|
|
return (
|
|
<div key={userId} className={`score-entry ${isCurrentUser ? 'current-user' : ''}`}>
|
|
<span className="player-name">
|
|
{isCurrentUser && "👉 "}
|
|
{playerName}
|
|
{isHost && (
|
|
<FontAwesomeIcon icon={faCrown} className="host-icon" />
|
|
)}
|
|
</span>
|
|
<span className="player-score">{playerScore}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{isHost ? (
|
|
<button className="next-round-button" onClick={handleNextRound}>
|
|
Nächste Runde
|
|
</button>
|
|
) : (
|
|
<p className="waiting-message">Warten auf Rundenwechsel durch Host...</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
default:
|
|
return <div>Unknown phase</div>;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={`game-page ${phase === "composing" ? "with-otamatone" : ""}`}>
|
|
<div className="background-overlay">
|
|
<div className="rotating-gradient"></div>
|
|
</div>
|
|
|
|
<div className="game-header">
|
|
<div className="game-role">
|
|
<FontAwesomeIcon icon={role === "composer" ? faMusic : faHeadphones} />
|
|
<span>{role === "composer" ? "Komponist" : "Rater"}</span>
|
|
</div>
|
|
<h2>ToneGuessr</h2>
|
|
<div className="game-round">Runde {round}</div>
|
|
</div>
|
|
|
|
<div className="game-layout">
|
|
<div className="main-content">
|
|
{renderPhaseContent()}
|
|
</div>
|
|
|
|
<div className="chat-panel">
|
|
<div className="chat-header">
|
|
<FontAwesomeIcon icon={faMessage} />
|
|
<div className="chat-title">Chat</div>
|
|
</div>
|
|
<div className="chat-messages">
|
|
{messages.map((message, index) => (
|
|
<div key={index} className={`message ${message.system ? 'system-message' : ''}`}>
|
|
{message.system ? (
|
|
<span className="message-text system">{message.text}</span>
|
|
) : (
|
|
<>
|
|
<span className="message-sender">{message.sender}:</span>
|
|
<span className="message-text">{message.text}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
))}
|
|
<div ref={messageEndRef}></div>
|
|
</div>
|
|
<div className="chat-input">
|
|
<input type="text" value={inputValue}
|
|
onChange={(e) => setInputValue(e.target.value)}
|
|
onKeyPress={(e) => e.key === 'Enter' && handleSendMessage()}
|
|
placeholder="Nachricht..."
|
|
/>
|
|
<button onClick={handleSendMessage} className="send-button">
|
|
<FontAwesomeIcon icon={faPaperPlane} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{phase === "composing" && (
|
|
<MusicSlider
|
|
isReadOnly={role !== "composer"}
|
|
onFrequencyChange={handleFrequencyChange}
|
|
frequency={frequency}
|
|
composerIsPlaying={composerIsPlaying}
|
|
/>
|
|
)}
|
|
|
|
{songsLoading && (
|
|
<div className="songs-loading-indicator">
|
|
Loading songs...
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Game; |