From 7d7dd263fea06c1ea290a68b13d4c68510dbfad9 Mon Sep 17 00:00:00 2001 From: Mathias Wagner Date: Sat, 1 Mar 2025 00:18:01 +0100 Subject: [PATCH] Implement main game mechanic --- client/src/common/contexts/SocketContext.jsx | 117 ++++++ client/src/pages/Game/Game.jsx | 351 ++++++++++++++++-- .../components/MusicSlider/MusicSlider.jsx | 46 ++- client/src/pages/Game/styles.sass | 80 ++++ server/controller/game.js | 198 ++++++++++ server/controller/room.js | 33 +- server/handler/connection.js | 198 ++++++++-- server/index.js | 33 +- 8 files changed, 991 insertions(+), 65 deletions(-) create mode 100644 client/src/common/contexts/SocketContext.jsx create mode 100644 server/controller/game.js diff --git a/client/src/common/contexts/SocketContext.jsx b/client/src/common/contexts/SocketContext.jsx new file mode 100644 index 0000000..7e49e8d --- /dev/null +++ b/client/src/common/contexts/SocketContext.jsx @@ -0,0 +1,117 @@ +import { createContext, useState, useEffect, useCallback, useRef } from 'react'; +import { io } from 'socket.io-client'; + +export const SocketContext = createContext(); + +export const SocketProvider = ({ children }) => { + const [socket, setSocket] = useState(null); + const [connected, setConnected] = useState(false); + const connectAttempts = useRef(0); + const maxAttempts = 3; + const reconnectDelay = 2000; // ms + + // Connect to socket server + const connect = useCallback(() => { + if (socket) return; + + // Determine server URL based on environment + const serverUrl = process.env.NODE_ENV === 'production' + ? window.location.origin + : 'http://localhost:5287'; + + try { + const newSocket = io(serverUrl, { + reconnectionAttempts: 3, + timeout: 10000, + reconnectionDelay: 1000 + }); + + newSocket.on('connect', () => { + console.log('Socket connected with ID:', newSocket.id); + setConnected(true); + connectAttempts.current = 0; + + // Store player ID in localStorage for persistence + localStorage.setItem('playerId', newSocket.id); + }); + + newSocket.on('disconnect', (reason) => { + console.log('Socket disconnected:', reason); + setConnected(false); + }); + + newSocket.on('connect_error', (error) => { + console.error('Connection error:', error); + connectAttempts.current += 1; + + if (connectAttempts.current >= maxAttempts) { + console.error('Max connection attempts reached, giving up'); + newSocket.disconnect(); + } + }); + + setSocket(newSocket); + } catch (err) { + console.error('Error creating socket connection:', err); + } + }, [socket]); + + // Disconnect socket + const disconnect = useCallback(() => { + if (socket) { + socket.disconnect(); + setSocket(null); + setConnected(false); + console.log('Socket manually disconnected'); + } + }, [socket]); + + // Send event through socket + const send = useCallback((event, data) => { + if (!socket || !connected) { + console.warn(`Cannot send event "${event}": socket not connected`); + return false; + } + + try { + socket.emit(event, data); + return true; + } catch (err) { + console.error(`Error sending event "${event}":`, err); + return false; + } + }, [socket, connected]); + + // Register event listener + const on = useCallback((event, callback) => { + if (!socket) { + console.warn(`Cannot listen for event "${event}": socket not initialized`); + return () => {}; + } + + socket.on(event, callback); + return () => socket.off(event, callback); + }, [socket]); + + // Clean up socket on component unmount + useEffect(() => { + return () => { + if (socket) { + socket.disconnect(); + } + }; + }, [socket]); + + return ( + + {children} + + ); +}; diff --git a/client/src/pages/Game/Game.jsx b/client/src/pages/Game/Game.jsx index 53ff219..6daa3ff 100644 --- a/client/src/pages/Game/Game.jsx +++ b/client/src/pages/Game/Game.jsx @@ -1,19 +1,144 @@ import "./styles.sass"; -import {StateContext} from "@/common/contexts/StateContext"; import {SocketContext} from "@/common/contexts/SocketContext"; import {useContext, useState, useEffect, useRef} from "react"; import MusicSlider from "@/pages/Game/components/MusicSlider"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {faMessage} from "@fortawesome/free-solid-svg-icons"; +import {faMessage, faMusic, faHeadphones, faClock, faCrown} from "@fortawesome/free-solid-svg-icons"; export const Game = () => { - const {setCurrentState} = useContext(StateContext); const {send, on, socket} = useContext(SocketContext); + + const [role, setRole] = useState(null); // 'composer' or 'guesser' + const [round, setRound] = useState(1); + const [phase, setPhase] = useState("waiting"); // waiting, composing, guessing, results + 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); + + // Chat state const [messages, setMessages] = useState([]); const [inputValue, setInputValue] = useState(""); const [connectedUsers, setConnectedUsers] = useState([]); const [username, setUsername] = useState(""); const messageEndRef = useRef(null); + const timerIntervalRef = useRef(null); + + useEffect(() => { + setPhase("waiting"); + + const handleRolesAssigned = (roles) => { + console.log("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." + }]); + } else { + console.error("No role assigned to this player!"); + } + }; + + const handleSongSelected = (song) => { + console.log("Song selected:", song); + setCurrentSong(song); + }; + + const handleRoundStarted = (data) => { + console.log("Round started:", data); + setRound(data.round); + setPhase("composing"); + setTimeLeft(data.timeRemaining); + }; + + const handleGuessingPhaseStarted = (data) => { + console.log("Guessing phase started:", data); + setPhase("guessing"); + setTimeLeft(data.timeRemaining); + setSongOptions(data.songOptions); + }; + + const handleGuessResult = (result) => { + console.log("Guess result:", result); + setGuessResult(result.isCorrect); + setCurrentSong(result.correctSong); + }; + + const handleRoundResults = (results) => { + console.log("Round results:", results); + setPhase("results"); + setScores(results.scores); + if (!currentSong) { + setCurrentSong(results.selectedSong); + } + }; + + const handleRoomUsers = (users) => { + console.log("Room users:", users); + setConnectedUsers(users); + + const currentUser = users.find(u => u.id === socket?.id); + if (currentUser) { + setUsername(currentUser.name); + } + }; + + const handleHostStatus = (status) => { + setIsHost(status.isHost); + }; + + const cleanupRolesAssigned = on("roles-assigned", handleRolesAssigned); + const cleanupSongSelected = on("song-selected", handleSongSelected); + const cleanupRoundStarted = on("round-started", handleRoundStarted); + const cleanupGuessingPhaseStarted = on("guessing-phase-started", handleGuessingPhaseStarted); + const cleanupGuessResult = on("guess-result", handleGuessResult); + const cleanupRoundResults = on("round-results", handleRoundResults); + const cleanupRoomUsers = on("room-users-update", handleRoomUsers); + const cleanupHostStatus = on("host-status", handleHostStatus); + + send("get-room-users"); + + send("check-host-status"); + + return () => { + cleanupRolesAssigned(); + cleanupSongSelected(); + cleanupRoundStarted(); + cleanupGuessingPhaseStarted(); + cleanupGuessResult(); + cleanupRoundResults(); + cleanupRoomUsers(); + cleanupHostStatus(); + }; + }, [socket, on, send]); + + 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(() => { const handleChatMessage = (messageData) => { @@ -41,31 +166,36 @@ export const Game = () => { }); }; - if (socket && socket.id) { - send("get-user-info"); - } - - const handleUserInfo = (userInfo) => { - setUsername(userInfo.name); + const handleFrequencyUpdate = (data) => { + if (role === "guesser") { + setFrequency(data.frequency); + } }; - + const cleanupChatMessage = on("chat-message", handleChatMessage); const cleanupUserConnected = on("user-connected", handleUserConnected); const cleanupUserDisconnected = on("user-disconnected", handleUserDisconnected); - const cleanupUserInfo = on("user-info", handleUserInfo); + const cleanupFrequencyUpdate = on("frequency-update", handleFrequencyUpdate); return () => { cleanupChatMessage(); cleanupUserConnected(); cleanupUserDisconnected(); - cleanupUserInfo(); + cleanupFrequencyUpdate(); }; - }, [on, send, socket]); + }, [on, role]); useEffect(() => { messageEndRef.current?.scrollIntoView({behavior: "smooth"}); }, [messages]); + const handleFrequencyChange = (newFrequency) => { + setFrequency(newFrequency); + if (role === "composer") { + send("submit-frequency", { frequency: newFrequency }); + } + }; + const handleSendMessage = () => { if (inputValue.trim()) { const messageData = { @@ -77,6 +207,173 @@ export const Game = () => { setInputValue(""); } }; + + const handleSongSelect = (song) => { + setSelectedSong(song); + }; + + const handleNextRound = () => { + send("next-round"); + + setSelectedSong(null); + setGuessResult(null); + setTimeLeft(0); + }; + + const renderPhaseContent = () => { + switch (phase) { + case "waiting": + return ( +
+

Warten auf Spielstart...

+
+ ); + + case "composing": + return ( +
+
+

Runde {round}: {role === "composer" ? "Spielen" : "Zuhören"}

+
+ {timeLeft}s +
+
+ + {role === "composer" && currentSong && ( +
+
+ {currentSong.title} +
+
{currentSong.title}
+
von {currentSong.artist}
+
+
+

Spiele diesen Song mit dem Tonregler!

+
+ )} + + {role === "guesser" && ( +
+
+ +
+

Höre genau zu und versuche, den Song zu erkennen!

+
+ )} +
+ ); + + case "guessing": + return ( +
+
+

Runde {round}: Song erraten

+
+ {timeLeft}s +
+
+ + {role === "composer" && ( +
+

Die Rater versuchen nun, deinen Song zu erraten...

+
+ )} + + {role === "guesser" && ( +
+

Welchen Song hat der Komponist gespielt?

+
+ {songOptions.map(song => ( +
handleSongSelect(song)} + > + {song.title} +
+
{song.title}
+
{song.artist}
+
+
+ ))} +
+
+ )} +
+ ); + + case "results": + return ( +
+

Runde {round}: Ergebnisse

+ +
+ {role === "composer" && ( +
+

Die Rater haben versucht, deinen Song zu erraten.

+
+ )} + + {role === "guesser" && ( +
+ {currentSong && ( +
+

Der richtige Song war:

+
+ {currentSong.title} +
+
{currentSong.title}
+
von {currentSong.artist}
+
+
+
+ )} + + {guessResult !== null && ( +
+ {guessResult + ? 'Richtig! Du erhälst 10 Punkte.' + : 'Leider falsch. Kein Punkt.'} +
+ )} +
+ )} + +
+

Punktestand

+
+ {Object.entries(scores).map(([userId, score]) => { + const user = connectedUsers.find(u => u.id === userId) || { name: 'Unbekannt' }; + return ( +
+ + {user.id === (socket?.id || "you") && "👉 "} + {user.name} + {userId === connectedUsers.find(u => u.id === "user1")?.id && ( + + )} + + {score} +
+ ); + })} +
+
+ + {isHost && ( + + )} + {!isHost &&

Warten auf Rundenwechsel durch Host...

} +
+
+ ); + + default: + return
Unknown phase
; + } + }; return (
@@ -84,17 +381,18 @@ export const Game = () => {
-
-
-

ToneGuessr

-
- Song -
-
Black Steam
-
von Carrot Quest GmbH
-
-
+
+
+ + {role === "composer" ? "Komponist" : "Rater"}
+

ToneGuessr

+
Runde {round}
+
+ +
+ {renderPhaseContent()} +
@@ -125,7 +423,12 @@ export const Game = () => {
- + +
); } \ No newline at end of file diff --git a/client/src/pages/Game/components/MusicSlider/MusicSlider.jsx b/client/src/pages/Game/components/MusicSlider/MusicSlider.jsx index d54b4d9..2341be9 100644 --- a/client/src/pages/Game/components/MusicSlider/MusicSlider.jsx +++ b/client/src/pages/Game/components/MusicSlider/MusicSlider.jsx @@ -1,8 +1,8 @@ import {useEffect, useRef, useState} from "react"; import "./styles.sass"; -export const MusicSlider = () => { - const [frequency, setFrequency] = useState(440); +export const MusicSlider = ({ isReadOnly = false, onFrequencyChange, frequency: externalFrequency }) => { + const [frequency, setFrequency] = useState(externalFrequency || 440); const [isPlaying, setIsPlaying] = useState(false); const [dragging, setDragging] = useState(false); const audioContextRef = useRef(null); @@ -10,7 +10,19 @@ export const MusicSlider = () => { const gainNodeRef = useRef(null); const sliderRef = useRef(null); + useEffect(() => { + if (externalFrequency !== undefined && !dragging) { + setFrequency(externalFrequency); + + if (!isPlaying && !isReadOnly) { + startAudio(); + } + } + }, [externalFrequency, dragging, isPlaying, isReadOnly]); + const handleMouseDown = (e) => { + if (isReadOnly) return; + setDragging(true); startAudio(); handleFrequencyChange(e); @@ -18,15 +30,23 @@ export const MusicSlider = () => { const handleMouseUp = () => { setDragging(false); - stopAudio(); + if (!isReadOnly) { + stopAudio(); + } }; const handleFrequencyChange = (e) => { + if (isReadOnly) return; + const rect = sliderRef.current.getBoundingClientRect(); const x = e.clientX - rect.left; const clampedX = Math.max(0, Math.min(x, rect.width)); const newFrequency = 20 + (clampedX / rect.width) * 1980; setFrequency(newFrequency); + + if (onFrequencyChange) { + onFrequencyChange(newFrequency); + } }; useEffect(() => { @@ -80,9 +100,25 @@ export const MusicSlider = () => { setIsPlaying(false); }; + useEffect(() => { + return () => { + if (oscillatorRef.current) { + oscillatorRef.current.stop(); + oscillatorRef.current.disconnect(); + } + if (audioContextRef.current && audioContextRef.current.state !== 'closed') { + audioContextRef.current.close(); + } + }; + }, []); + return ( -
-
+
+