Add Battle Result Screen component and associated styles for displaying battle outcomes
Some checks failed
Publish Docker image / Push Docker image to Docker Hub (push) Failing after 6m59s
Some checks failed
Publish Docker image / Push Docker image to Docker Hub (push) Failing after 6m59s
This commit is contained in:
parent
0c543a1a01
commit
8f91e27ca1
@ -7,6 +7,7 @@ import HomeScreen from "./components/HomeScreen";
|
|||||||
import SongSubmissionScreen from "./components/SongSubmissionScreen";
|
import SongSubmissionScreen from "./components/SongSubmissionScreen";
|
||||||
import VotingScreen from "./components/VotingScreen";
|
import VotingScreen from "./components/VotingScreen";
|
||||||
import ResultsScreen from "./components/ResultsScreen";
|
import ResultsScreen from "./components/ResultsScreen";
|
||||||
|
import BattleResultScreen from "./components/BattleResultScreen";
|
||||||
import ConnectionStatus from "./components/ConnectionStatus";
|
import ConnectionStatus from "./components/ConnectionStatus";
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
@ -52,6 +53,8 @@ const App = () => {
|
|||||||
return <VotingScreen />;
|
return <VotingScreen />;
|
||||||
case 'FINISHED':
|
case 'FINISHED':
|
||||||
return <ResultsScreen />;
|
return <ResultsScreen />;
|
||||||
|
case 'BATTLE':
|
||||||
|
return <BattleResultScreen />;
|
||||||
default:
|
default:
|
||||||
return <HomeScreen />;
|
return <HomeScreen />;
|
||||||
}
|
}
|
||||||
|
243
client/src/common/styles/components/battle-result-screen.sass
Normal file
243
client/src/common/styles/components/battle-result-screen.sass
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
// Battle Result Screen styles
|
||||||
|
|
||||||
|
.battle-result-screen
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
min-height: 100%
|
||||||
|
padding: 1.5rem
|
||||||
|
position: relative
|
||||||
|
overflow: hidden
|
||||||
|
|
||||||
|
header
|
||||||
|
display: flex
|
||||||
|
justify-content: center
|
||||||
|
align-items: center
|
||||||
|
flex-direction: column
|
||||||
|
margin-bottom: 2rem
|
||||||
|
|
||||||
|
h1
|
||||||
|
margin: 0
|
||||||
|
color: $accent
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 0.75rem
|
||||||
|
font-size: 2.2rem
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
text-transform: uppercase
|
||||||
|
animation: winner-pulse 2s infinite alternate
|
||||||
|
|
||||||
|
svg
|
||||||
|
color: $accent
|
||||||
|
filter: drop-shadow(2px 2px 0 #000)
|
||||||
|
|
||||||
|
.countdown
|
||||||
|
margin-top: 1rem
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 0.9rem
|
||||||
|
color: $text-muted
|
||||||
|
padding: 0.8rem 1.2rem
|
||||||
|
background-color: rgba(0, 0, 0, 0.3)
|
||||||
|
border-radius: 1rem
|
||||||
|
animation: pulse 1s infinite alternate
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 0.5rem
|
||||||
|
|
||||||
|
svg
|
||||||
|
color: $accent
|
||||||
|
margin-right: 0.5rem
|
||||||
|
|
||||||
|
.winner-announcement
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
align-items: center
|
||||||
|
margin-bottom: 2rem
|
||||||
|
|
||||||
|
.song-cards
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
width: 100%
|
||||||
|
max-width: 700px
|
||||||
|
gap: 2rem
|
||||||
|
|
||||||
|
@media (min-width: 768px)
|
||||||
|
flex-direction: row
|
||||||
|
align-items: flex-start
|
||||||
|
|
||||||
|
.song-card
|
||||||
|
position: relative
|
||||||
|
background-color: rgba(0, 0, 0, 0.3)
|
||||||
|
padding: 1.5rem
|
||||||
|
border-radius: 1rem
|
||||||
|
|
||||||
|
&.winner
|
||||||
|
flex: 3
|
||||||
|
border: 6px solid $accent
|
||||||
|
box-shadow: 0 0 20px rgba($accent, 0.3)
|
||||||
|
transform: scale(1.05)
|
||||||
|
z-index: 1
|
||||||
|
|
||||||
|
.victory-badge
|
||||||
|
position: absolute
|
||||||
|
top: -12px
|
||||||
|
right: 10px
|
||||||
|
background-color: $accent
|
||||||
|
color: white
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 0.7rem
|
||||||
|
padding: 0.5rem 1rem
|
||||||
|
border-radius: 0.5rem
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 0.5rem
|
||||||
|
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.3)
|
||||||
|
|
||||||
|
svg
|
||||||
|
font-size: 0.8rem
|
||||||
|
|
||||||
|
&.loser
|
||||||
|
flex: 2
|
||||||
|
opacity: 0.7
|
||||||
|
filter: saturate(0.7)
|
||||||
|
|
||||||
|
.versus
|
||||||
|
position: absolute
|
||||||
|
top: 50%
|
||||||
|
left: -30px
|
||||||
|
font-family: 'Bangers', cursive
|
||||||
|
font-size: 2.5rem
|
||||||
|
color: $accent
|
||||||
|
transform: translateY(-50%)
|
||||||
|
text-shadow: 0 0 5px rgba($accent, 0.5)
|
||||||
|
|
||||||
|
@media (max-width: 767px)
|
||||||
|
top: -25px
|
||||||
|
left: 50%
|
||||||
|
transform: translateX(-50%)
|
||||||
|
|
||||||
|
.song-info
|
||||||
|
margin-bottom: 1.5rem
|
||||||
|
|
||||||
|
h2, h3
|
||||||
|
margin: 0 0 0.5rem 0
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
|
||||||
|
h2
|
||||||
|
font-size: 1.4rem
|
||||||
|
color: $text
|
||||||
|
margin-right: 70px
|
||||||
|
|
||||||
|
h3
|
||||||
|
font-size: 1.2rem
|
||||||
|
color: $text
|
||||||
|
|
||||||
|
.artist
|
||||||
|
color: $text-muted
|
||||||
|
font-size: 1.1rem
|
||||||
|
margin-bottom: 1rem
|
||||||
|
|
||||||
|
.submitter
|
||||||
|
font-size: 0.9rem
|
||||||
|
color: $text-muted
|
||||||
|
font-style: italic
|
||||||
|
margin-bottom: 0.5rem
|
||||||
|
|
||||||
|
.vote-count
|
||||||
|
display: inline-block
|
||||||
|
padding: 0.5rem 1rem
|
||||||
|
background-color: rgba(0, 0, 0, 0.3)
|
||||||
|
border-radius: 1rem
|
||||||
|
font-family: 'Press Start 2P', monospace
|
||||||
|
font-size: 0.8rem
|
||||||
|
|
||||||
|
.votes
|
||||||
|
color: $text
|
||||||
|
|
||||||
|
.winner-video
|
||||||
|
width: 100%
|
||||||
|
aspect-ratio: 16 / 9
|
||||||
|
border-radius: 0.5rem
|
||||||
|
overflow: hidden
|
||||||
|
|
||||||
|
.no-video
|
||||||
|
width: 100%
|
||||||
|
aspect-ratio: 16 / 9
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
background-color: rgba(0, 0, 0, 0.3)
|
||||||
|
border-radius: 0.5rem
|
||||||
|
color: $text-muted
|
||||||
|
|
||||||
|
.pulse-icon
|
||||||
|
font-size: 2rem
|
||||||
|
margin-bottom: 1rem
|
||||||
|
animation: pulse 2s infinite
|
||||||
|
|
||||||
|
.battle-actions
|
||||||
|
display: flex
|
||||||
|
justify-content: center
|
||||||
|
margin-top: auto
|
||||||
|
padding-top: 1.5rem
|
||||||
|
|
||||||
|
.btn
|
||||||
|
padding: 1rem 2rem
|
||||||
|
font-size: 1.1rem
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 0.5rem
|
||||||
|
|
||||||
|
&.pixelated
|
||||||
|
position: relative
|
||||||
|
|
||||||
|
.pixel-corner
|
||||||
|
position: absolute
|
||||||
|
width: 8px
|
||||||
|
height: 8px
|
||||||
|
background-color: $primary
|
||||||
|
|
||||||
|
&.tl
|
||||||
|
top: -4px
|
||||||
|
left: -4px
|
||||||
|
|
||||||
|
&.tr
|
||||||
|
top: -4px
|
||||||
|
right: -4px
|
||||||
|
|
||||||
|
&.bl
|
||||||
|
bottom: -4px
|
||||||
|
left: -4px
|
||||||
|
|
||||||
|
&.br
|
||||||
|
bottom: -4px
|
||||||
|
right: -4px
|
||||||
|
|
||||||
|
// Confetti animation
|
||||||
|
.confetti
|
||||||
|
position: absolute
|
||||||
|
width: 10px
|
||||||
|
height: 20px
|
||||||
|
transform-origin: center bottom
|
||||||
|
animation: confetti-fall 3s linear forwards
|
||||||
|
z-index: -1
|
||||||
|
|
||||||
|
@keyframes confetti-fall
|
||||||
|
0%
|
||||||
|
transform: translateY(-100vh) rotate(0deg)
|
||||||
|
100%
|
||||||
|
transform: translateY(100vh) rotate(360deg)
|
||||||
|
|
||||||
|
@keyframes winner-pulse
|
||||||
|
0%
|
||||||
|
transform: scale(1)
|
||||||
|
text-shadow: 2px 2px 0 #000, -2px -2px 0 #000, 2px -2px 0 #000, -2px 2px 0 #000
|
||||||
|
100%
|
||||||
|
transform: scale(1.05)
|
||||||
|
text-shadow: 3px 3px 0 #000, -3px -3px 0 #000, 3px -3px 0 #000, -3px 3px 0 #000
|
||||||
|
|
||||||
|
@keyframes pulse
|
||||||
|
0%
|
||||||
|
opacity: 0.7
|
||||||
|
100%
|
||||||
|
opacity: 1
|
@ -9,6 +9,7 @@
|
|||||||
@import './components/song-submission-screen'
|
@import './components/song-submission-screen'
|
||||||
@import './components/voting-screen'
|
@import './components/voting-screen'
|
||||||
@import './components/results-screen'
|
@import './components/results-screen'
|
||||||
|
@import './components/battle-result-screen'
|
||||||
@import './components/youtube-embed'
|
@import './components/youtube-embed'
|
||||||
@import './components/youtube-search'
|
@import './components/youtube-search'
|
||||||
@import './components/song-form-overlay'
|
@import './components/song-form-overlay'
|
||||||
|
198
client/src/components/BattleResultScreen.jsx
Normal file
198
client/src/components/BattleResultScreen.jsx
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useGame } from '../context/GameContext';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faTrophy, faMusic, faCrown, faClock } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import YouTubeEmbed from './YouTubeEmbed';
|
||||||
|
import { getDisplayName } from '../utils/playerUtils';
|
||||||
|
|
||||||
|
const BattleResultScreen = () => {
|
||||||
|
const { lobby, currentPlayer, isHost, proceedToNextBattle } = useGame();
|
||||||
|
const [countdown, setCountdown] = useState(10); // Auto-advance after 10 seconds
|
||||||
|
const [showConfetti, setShowConfetti] = useState(true);
|
||||||
|
|
||||||
|
// Use previousBattle from the lobby state
|
||||||
|
const previousBattle = lobby?.previousBattle;
|
||||||
|
|
||||||
|
// Extract winning song
|
||||||
|
const winnerSongId = previousBattle?.winner;
|
||||||
|
const winningSong = winnerSongId === previousBattle?.song1?.id
|
||||||
|
? previousBattle?.song1
|
||||||
|
: previousBattle?.song2;
|
||||||
|
|
||||||
|
const losingSong = winnerSongId === previousBattle?.song1?.id
|
||||||
|
? previousBattle?.song2
|
||||||
|
: previousBattle?.song1;
|
||||||
|
|
||||||
|
const winningVotes = winnerSongId === previousBattle?.song1?.id
|
||||||
|
? previousBattle?.song1Votes
|
||||||
|
: previousBattle?.song2Votes;
|
||||||
|
|
||||||
|
const losingVotes = winnerSongId === previousBattle?.song1?.id
|
||||||
|
? previousBattle?.song2Votes
|
||||||
|
: previousBattle?.song1Votes;
|
||||||
|
|
||||||
|
// Get the player who submitted the winning song
|
||||||
|
const findSubmitter = (song) => {
|
||||||
|
if (!lobby || !song) return null;
|
||||||
|
|
||||||
|
const submitter = lobby.players.find(p => p.id === song.submittedById);
|
||||||
|
return submitter;
|
||||||
|
};
|
||||||
|
|
||||||
|
const winnerSubmitter = findSubmitter(winningSong);
|
||||||
|
|
||||||
|
// YouTube video ID extraction for the winner
|
||||||
|
const getYouTubeId = (url) => {
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
|
||||||
|
const match = url.match(regExp);
|
||||||
|
|
||||||
|
return (match && match[2].length === 11) ? match[2] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const winnerVideoId = getYouTubeId(winningSong?.youtubeLink);
|
||||||
|
|
||||||
|
// Auto-advance countdown
|
||||||
|
useEffect(() => {
|
||||||
|
if (countdown > 0) {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setCountdown(prev => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
clearInterval(timer);
|
||||||
|
// Only auto-advance if host, otherwise wait for host
|
||||||
|
if (isHost) {
|
||||||
|
proceedToNextBattle();
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}
|
||||||
|
}, [countdown, isHost, proceedToNextBattle]);
|
||||||
|
|
||||||
|
// Confetti effect
|
||||||
|
useEffect(() => {
|
||||||
|
if (showConfetti) {
|
||||||
|
const createConfetti = () => {
|
||||||
|
const confettiContainer = document.createElement('div');
|
||||||
|
confettiContainer.className = 'confetti';
|
||||||
|
|
||||||
|
// Random position, size, and color
|
||||||
|
const left = Math.random() * 100;
|
||||||
|
const colors = ['#f94144', '#f3722c', '#f8961e', '#f9c74f', '#90be6d', '#43aa8b', '#577590'];
|
||||||
|
const color = colors[Math.floor(Math.random() * colors.length)];
|
||||||
|
const size = Math.random() * 0.5 + 0.3; // Between 0.3 and 0.8rem
|
||||||
|
const rotation = Math.random() * 360; // Random rotation
|
||||||
|
const duration = Math.random() * 2 + 1; // Between 1 and 3 seconds
|
||||||
|
|
||||||
|
confettiContainer.style.left = `${left}%`;
|
||||||
|
confettiContainer.style.backgroundColor = color;
|
||||||
|
confettiContainer.style.width = `${size}rem`;
|
||||||
|
confettiContainer.style.height = `${size * 0.6}rem`;
|
||||||
|
confettiContainer.style.transform = `rotate(${rotation}deg)`;
|
||||||
|
confettiContainer.style.animation = `confetti-fall ${duration}s linear forwards`;
|
||||||
|
|
||||||
|
document.querySelector('.battle-result-screen').appendChild(confettiContainer);
|
||||||
|
|
||||||
|
// Remove after animation
|
||||||
|
setTimeout(() => {
|
||||||
|
confettiContainer.remove();
|
||||||
|
}, duration * 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create confetti pieces
|
||||||
|
const confettiInterval = setInterval(() => {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
createConfetti();
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
// Stop confetti after a few seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
clearInterval(confettiInterval);
|
||||||
|
setShowConfetti(false);
|
||||||
|
}, 4000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(confettiInterval);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [showConfetti]);
|
||||||
|
|
||||||
|
if (!previousBattle || !winningSong) {
|
||||||
|
return (
|
||||||
|
<div className="battle-result-screen">
|
||||||
|
<h2>Nächster Kampf wird vorbereitet...</h2>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="battle-result-screen">
|
||||||
|
<header>
|
||||||
|
<h1><FontAwesomeIcon icon={faTrophy} /> Gewinner dieser Runde</h1>
|
||||||
|
{countdown > 0 && (
|
||||||
|
<div className="countdown">
|
||||||
|
<FontAwesomeIcon icon={faClock} />
|
||||||
|
Nächster Kampf in {countdown}s
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="winner-announcement">
|
||||||
|
<div className="song-cards">
|
||||||
|
<div className="song-card winner">
|
||||||
|
<div className="victory-badge">
|
||||||
|
<FontAwesomeIcon icon={faCrown} /> Gewinner
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="song-info">
|
||||||
|
<h2>{winningSong.title}</h2>
|
||||||
|
<p className="artist">{winningSong.artist}</p>
|
||||||
|
|
||||||
|
{!lobby?.settings?.hidePlayerNames && winnerSubmitter && (
|
||||||
|
<p className="submitter">
|
||||||
|
Eingereicht von: {getDisplayName(winnerSubmitter, lobby, currentPlayer)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="vote-count">
|
||||||
|
<span className="votes">{winningVotes} Stimmen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{winnerVideoId ? (
|
||||||
|
<div className="winner-video">
|
||||||
|
<YouTubeEmbed videoId={winnerVideoId} autoplay={true} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="no-video">
|
||||||
|
<FontAwesomeIcon icon={faMusic} className="pulse-icon" />
|
||||||
|
<span>Kein Video verfügbar</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{losingSong && (
|
||||||
|
<div className="song-card loser">
|
||||||
|
<div className="versus">VS</div>
|
||||||
|
<div className="song-info">
|
||||||
|
<h3>{losingSong.title}</h3>
|
||||||
|
<p className="artist">{losingSong.artist}</p>
|
||||||
|
<div className="vote-count">
|
||||||
|
<span className="votes">{losingVotes} Stimmen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BattleResultScreen;
|
@ -439,6 +439,19 @@ export function GameProvider({ children }) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle battle ended and show result screen
|
||||||
|
const handleBattleEnded = (data) => {
|
||||||
|
console.log('Battle ended, showing result screen:', data);
|
||||||
|
setLobby(prevLobby => {
|
||||||
|
if (!prevLobby) return prevLobby;
|
||||||
|
return {
|
||||||
|
...prevLobby,
|
||||||
|
state: 'BATTLE',
|
||||||
|
previousBattle: data.previousBattle
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Game finished
|
// Game finished
|
||||||
const handleGameFinished = (data) => {
|
const handleGameFinished = (data) => {
|
||||||
setLobby(prevLobby => {
|
setLobby(prevLobby => {
|
||||||
@ -461,11 +474,13 @@ export function GameProvider({ children }) {
|
|||||||
socket.on('songs_updated', handleSongsUpdated);
|
socket.on('songs_updated', handleSongsUpdated);
|
||||||
socket.on('player_status_changed', handlePlayerStatusChanged);
|
socket.on('player_status_changed', handlePlayerStatusChanged);
|
||||||
socket.on('vote_submitted', handleVoteSubmitted);
|
socket.on('vote_submitted', handleVoteSubmitted);
|
||||||
|
socket.on('battle_ended', handleBattleEnded);
|
||||||
socket.on('tournament_started', data => {
|
socket.on('tournament_started', data => {
|
||||||
console.log('Tournament started event received:', data);
|
console.log('Tournament started event received:', data);
|
||||||
setLobby(prevLobby => prevLobby ? {...prevLobby, state: data.state} : prevLobby);
|
setLobby(prevLobby => prevLobby ? {...prevLobby, state: data.state} : prevLobby);
|
||||||
});
|
});
|
||||||
socket.on('new_battle', handleNewBattle);
|
socket.on('new_battle', handleNewBattle);
|
||||||
|
socket.on('battle_ended', handleBattleEnded);
|
||||||
socket.on('game_finished', handleGameFinished);
|
socket.on('game_finished', handleGameFinished);
|
||||||
|
|
||||||
// Clean up listeners on unmount
|
// Clean up listeners on unmount
|
||||||
@ -479,6 +494,7 @@ export function GameProvider({ children }) {
|
|||||||
socket.off('player_status_changed', handlePlayerStatusChanged);
|
socket.off('player_status_changed', handlePlayerStatusChanged);
|
||||||
socket.off('vote_submitted', handleVoteSubmitted);
|
socket.off('vote_submitted', handleVoteSubmitted);
|
||||||
socket.off('new_battle', handleNewBattle);
|
socket.off('new_battle', handleNewBattle);
|
||||||
|
socket.off('battle_ended', handleBattleEnded);
|
||||||
socket.off('game_finished', handleGameFinished);
|
socket.off('game_finished', handleGameFinished);
|
||||||
};
|
};
|
||||||
}, [socket]);
|
}, [socket]);
|
||||||
@ -706,6 +722,23 @@ export function GameProvider({ children }) {
|
|||||||
setCurrentPlayer(null);
|
setCurrentPlayer(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Proceed to next battle after the battle result screen
|
||||||
|
const proceedToNextBattle = () => {
|
||||||
|
if (!socket || !isConnected) {
|
||||||
|
setError('Not connected to server');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
safeEmit('proceed_to_next_battle', {}, (response) => {
|
||||||
|
if (response.error) {
|
||||||
|
setError(response.error);
|
||||||
|
} else if (response.lobby) {
|
||||||
|
setLobby(response.lobby);
|
||||||
|
updateSavedGameData(response.lobby);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GameContext.Provider value={{
|
<GameContext.Provider value={{
|
||||||
lobby,
|
lobby,
|
||||||
@ -724,7 +757,8 @@ export function GameProvider({ children }) {
|
|||||||
submitVote,
|
submitVote,
|
||||||
leaveLobby,
|
leaveLobby,
|
||||||
searchYouTube,
|
searchYouTube,
|
||||||
getYouTubeMetadata
|
getYouTubeMetadata,
|
||||||
|
proceedToNextBattle
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</GameContext.Provider>
|
</GameContext.Provider>
|
||||||
|
255
server/game.js
255
server/game.js
@ -635,11 +635,24 @@ class GameManager {
|
|||||||
song2: lobby.currentBattle.song2,
|
song2: lobby.currentBattle.song2,
|
||||||
song1Votes: lobby.currentBattle.song1Votes,
|
song1Votes: lobby.currentBattle.song1Votes,
|
||||||
song2Votes: 0,
|
song2Votes: 0,
|
||||||
winner: winnerSongId
|
winner: winnerSongId,
|
||||||
|
bye: true
|
||||||
});
|
});
|
||||||
|
|
||||||
// Move to next battle or finish tournament
|
// Store current battle for potential future reference
|
||||||
this._moveToNextBattle(lobby);
|
lobby.previousBattle = {
|
||||||
|
...lobby.currentBattle,
|
||||||
|
winner: winnerSongId
|
||||||
|
};
|
||||||
|
|
||||||
|
// Skip battle result screen for byes - go straight to the next battle
|
||||||
|
lobby.state = 'VOTING';
|
||||||
|
const tournamentFinished = this._moveToNextBattle(lobby);
|
||||||
|
|
||||||
|
// Check if the tournament has finished
|
||||||
|
if (tournamentFinished || lobby.state === 'FINISHED') {
|
||||||
|
console.log(`Tournament has finished after bye battle! Winner: ${lobby.finalWinner ? lobby.finalWinner.title : 'No winner'}`);
|
||||||
|
}
|
||||||
|
|
||||||
return { lobby, lobbyId };
|
return { lobby, lobbyId };
|
||||||
}
|
}
|
||||||
@ -666,8 +679,132 @@ class GameManager {
|
|||||||
winner: winnerSongId
|
winner: winnerSongId
|
||||||
});
|
});
|
||||||
|
|
||||||
// Move to next battle or finish tournament
|
// Store current battle as previousBattle and set state to BATTLE_RESULT
|
||||||
this._moveToNextBattle(lobby);
|
lobby.previousBattle = {
|
||||||
|
...lobby.currentBattle,
|
||||||
|
winner: winnerSongId
|
||||||
|
};
|
||||||
|
|
||||||
|
// Change state to BATTLE to show the battle result screen
|
||||||
|
lobby.state = 'BATTLE';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { lobby, lobbyId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proceed to the next battle after showing the battle result screen
|
||||||
|
* @param {string} playerId - ID of the player (should be host)
|
||||||
|
* @returns {Object} Updated lobby data or error
|
||||||
|
*/
|
||||||
|
proceedToNextBattle(playerId) {
|
||||||
|
const lobbyId = this.playerToLobby.get(playerId);
|
||||||
|
if (!lobbyId) {
|
||||||
|
return { error: 'Player not in a lobby' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lobby = this.lobbies.get(lobbyId);
|
||||||
|
|
||||||
|
// Check if player is the host
|
||||||
|
if (lobby.hostId !== playerId) {
|
||||||
|
return { error: 'Only the host can proceed to the next battle' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're in the battle result state
|
||||||
|
// Allow proceeding from either BATTLE or VOTING state to handle race conditions
|
||||||
|
if (lobby.state !== 'BATTLE' && lobby.state !== 'VOTING') {
|
||||||
|
console.log(`Warning: Attempting to proceed to next battle from state ${lobby.state}`);
|
||||||
|
return { error: 'Not in a valid state to proceed to next battle' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're in VOTING state, ensure we have a winner before proceeding
|
||||||
|
if (lobby.state === 'VOTING' && lobby.currentBattle && !lobby.currentBattle.winner) {
|
||||||
|
console.log('Warning: Attempting to proceed without a winner determined');
|
||||||
|
|
||||||
|
// Check if this is a bye battle (only one song, automatic advancement)
|
||||||
|
if (lobby.currentBattle.bye === true || !lobby.currentBattle.song2) {
|
||||||
|
console.log('Processing bye battle - automatic advancement');
|
||||||
|
// For bye battles, the winner is always song1
|
||||||
|
const winnerSongId = lobby.currentBattle.song1.id;
|
||||||
|
|
||||||
|
lobby.currentBattle.winner = winnerSongId;
|
||||||
|
lobby.currentBattle.song1Votes = 1; // Add a dummy vote for song1
|
||||||
|
|
||||||
|
// Store as previous battle for history
|
||||||
|
lobby.previousBattle = {
|
||||||
|
...lobby.currentBattle,
|
||||||
|
winner: winnerSongId
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add to battle history
|
||||||
|
lobby.battles.push({
|
||||||
|
round: lobby.currentBattle.round,
|
||||||
|
song1: lobby.currentBattle.song1,
|
||||||
|
song2: lobby.currentBattle.song2,
|
||||||
|
song1Votes: 1, // Always at least one vote for the bye song
|
||||||
|
song2Votes: 0,
|
||||||
|
winner: winnerSongId,
|
||||||
|
bye: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Determine winner based on votes if present for regular battles
|
||||||
|
else if (lobby.currentBattle.votes && Object.keys(lobby.currentBattle.votes).length > 0) {
|
||||||
|
const winnerSongId = (lobby.currentBattle.song1Votes || 0) > (lobby.currentBattle.song2Votes || 0)
|
||||||
|
? lobby.currentBattle.song1.id
|
||||||
|
: lobby.currentBattle.song2.id;
|
||||||
|
|
||||||
|
lobby.currentBattle.winner = winnerSongId;
|
||||||
|
|
||||||
|
// Store as previous battle for history
|
||||||
|
lobby.previousBattle = {
|
||||||
|
...lobby.currentBattle,
|
||||||
|
winner: winnerSongId
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add to battle history
|
||||||
|
lobby.battles.push({
|
||||||
|
round: lobby.currentBattle.round,
|
||||||
|
song1: lobby.currentBattle.song1,
|
||||||
|
song2: lobby.currentBattle.song2,
|
||||||
|
song1Votes: lobby.currentBattle.song1Votes || 0,
|
||||||
|
song2Votes: lobby.currentBattle.song2Votes || 0,
|
||||||
|
winner: winnerSongId
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// For regular battles with no votes, force a winner to avoid getting stuck
|
||||||
|
console.log('No votes recorded but proceeding anyway to avoid blocking the game');
|
||||||
|
const winnerSongId = lobby.currentBattle.song1.id; // Default to song1 as winner
|
||||||
|
|
||||||
|
lobby.currentBattle.winner = winnerSongId;
|
||||||
|
lobby.currentBattle.song1Votes = 1; // Add a dummy vote
|
||||||
|
|
||||||
|
// Store as previous battle for history
|
||||||
|
lobby.previousBattle = {
|
||||||
|
...lobby.currentBattle,
|
||||||
|
winner: winnerSongId
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add to battle history
|
||||||
|
lobby.battles.push({
|
||||||
|
round: lobby.currentBattle.round,
|
||||||
|
song1: lobby.currentBattle.song1,
|
||||||
|
song2: lobby.currentBattle.song2,
|
||||||
|
song1Votes: 1,
|
||||||
|
song2Votes: 0,
|
||||||
|
winner: winnerSongId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set state to VOTING for the next battle
|
||||||
|
lobby.state = 'VOTING';
|
||||||
|
|
||||||
|
// Move to next battle or finish tournament - capture if the tournament has finished
|
||||||
|
const tournamentFinished = this._moveToNextBattle(lobby);
|
||||||
|
|
||||||
|
// Check if the game has ended after trying to move to the next battle
|
||||||
|
if (tournamentFinished || lobby.state === 'FINISHED') {
|
||||||
|
console.log(`Tournament has finished! Winner: ${lobby.finalWinner ? lobby.finalWinner.title : 'No winner'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { lobby, lobbyId };
|
return { lobby, lobbyId };
|
||||||
@ -854,18 +991,81 @@ class GameManager {
|
|||||||
/**
|
/**
|
||||||
* Move to next battle or finish tournament
|
* Move to next battle or finish tournament
|
||||||
* @param {Object} lobby - Lobby object
|
* @param {Object} lobby - Lobby object
|
||||||
|
* @returns {boolean} true if tournament has finished, false otherwise
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
_moveToNextBattle(lobby) {
|
_moveToNextBattle(lobby) {
|
||||||
|
// If coming from BATTLE state, reset to VOTING for the next battle
|
||||||
|
if (lobby.state === 'BATTLE') {
|
||||||
|
lobby.state = 'VOTING';
|
||||||
|
}
|
||||||
|
|
||||||
// Check if there are more battles in current round
|
// Check if there are more battles in current round
|
||||||
lobby.currentBracketIndex++;
|
lobby.currentBracketIndex++;
|
||||||
|
|
||||||
if (lobby.currentBracketIndex < lobby.brackets.length) {
|
if (lobby.currentBracketIndex < lobby.brackets.length) {
|
||||||
// Move to next battle
|
// Move to next battle
|
||||||
lobby.currentBattle = lobby.brackets[lobby.currentBracketIndex];
|
lobby.currentBattle = lobby.brackets[lobby.currentBracketIndex];
|
||||||
return;
|
|
||||||
|
// Ensure votes is initialized as an object
|
||||||
|
if (!lobby.currentBattle.votes) {
|
||||||
|
lobby.currentBattle.votes = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize vote count for UI
|
||||||
|
lobby.currentBattle.voteCount = 0;
|
||||||
|
|
||||||
|
// Check if this is a bye battle that should be auto-advanced
|
||||||
|
if (lobby.currentBattle.bye === true) {
|
||||||
|
console.log('Auto-advancing bye battle');
|
||||||
|
// Auto-advance logic for bye battles
|
||||||
|
const winnerSongId = lobby.currentBattle.song1.id;
|
||||||
|
|
||||||
|
lobby.currentBattle.winner = winnerSongId;
|
||||||
|
lobby.currentBattle.song1Votes = 1; // Add a dummy vote
|
||||||
|
|
||||||
|
// Store to history
|
||||||
|
lobby.battles.push({
|
||||||
|
round: lobby.currentBattle.round,
|
||||||
|
song1: lobby.currentBattle.song1,
|
||||||
|
song2: null,
|
||||||
|
song1Votes: 1,
|
||||||
|
song2Votes: 0,
|
||||||
|
winner: winnerSongId,
|
||||||
|
bye: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Immediately advance to the next battle
|
||||||
|
return this._moveToNextBattle(lobby);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false; // Tournament continues
|
||||||
|
}
|
||||||
|
|
||||||
|
// No more battles in current round, set up the next round
|
||||||
|
return this._setUpNextRound(lobby);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shuffle array in-place using Fisher-Yates algorithm
|
||||||
|
* @param {Array} array - Array to shuffle
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_shuffleArray(array) {
|
||||||
|
for (let i = array.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[array[i], array[j]] = [array[j], array[i]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to set up the next tournament round
|
||||||
|
* Similar logic to end of _moveToNextBattle but extracted for reuse
|
||||||
|
* @param {Object} lobby - Lobby object
|
||||||
|
* @returns {boolean} true if tournament has finished, false otherwise
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_setUpNextRound(lobby) {
|
||||||
// Current round complete, check if tournament is finished
|
// Current round complete, check if tournament is finished
|
||||||
const winners = lobby.brackets
|
const winners = lobby.brackets
|
||||||
.filter(b => !b.bye) // Skip byes
|
.filter(b => !b.bye) // Skip byes
|
||||||
@ -884,9 +1084,10 @@ class GameManager {
|
|||||||
// If only one song remains, we have a winner
|
// If only one song remains, we have a winner
|
||||||
if (nextRoundSongs.length <= 1) {
|
if (nextRoundSongs.length <= 1) {
|
||||||
lobby.state = 'FINISHED';
|
lobby.state = 'FINISHED';
|
||||||
lobby.finalWinner = nextRoundSongs[0];
|
lobby.finalWinner = nextRoundSongs.length > 0 ? nextRoundSongs[0] : null;
|
||||||
lobby.currentBattle = null;
|
lobby.currentBattle = null;
|
||||||
return;
|
console.log(`Tournament has finished! Final winner: ${lobby.finalWinner ? lobby.finalWinner.title : 'No winner'}`);
|
||||||
|
return true; // Return true to indicate tournament has finished
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create brackets for next round
|
// Create brackets for next round
|
||||||
@ -906,7 +1107,9 @@ class GameManager {
|
|||||||
song2Votes: 0,
|
song2Votes: 0,
|
||||||
votes: {} // Initialize votes as an object
|
votes: {} // Initialize votes as an object
|
||||||
});
|
});
|
||||||
} // Create pairs for next round
|
}
|
||||||
|
|
||||||
|
// Create pairs for next round
|
||||||
for (let i = 0; i < nextRoundSongs.length; i += 2) {
|
for (let i = 0; i < nextRoundSongs.length; i += 2) {
|
||||||
if (i + 1 < nextRoundSongs.length) {
|
if (i + 1 < nextRoundSongs.length) {
|
||||||
nextRound.push({
|
nextRound.push({
|
||||||
@ -936,19 +1139,33 @@ class GameManager {
|
|||||||
|
|
||||||
// Initialize vote count for UI
|
// Initialize vote count for UI
|
||||||
lobby.currentBattle.voteCount = 0;
|
lobby.currentBattle.voteCount = 0;
|
||||||
|
|
||||||
|
// Check if this first battle is a bye - if so, auto-advance
|
||||||
|
if (lobby.currentBattle.bye === true) {
|
||||||
|
console.log('Auto-advancing bye battle in new round');
|
||||||
|
// Auto-advance logic similar to above
|
||||||
|
const winnerSongId = lobby.currentBattle.song1.id;
|
||||||
|
|
||||||
|
lobby.currentBattle.winner = winnerSongId;
|
||||||
|
lobby.currentBattle.song1Votes = 1;
|
||||||
|
|
||||||
|
// Store to history
|
||||||
|
lobby.battles.push({
|
||||||
|
round: lobby.currentBattle.round,
|
||||||
|
song1: lobby.currentBattle.song1,
|
||||||
|
song2: null,
|
||||||
|
song1Votes: 1,
|
||||||
|
song2Votes: 0,
|
||||||
|
winner: winnerSongId,
|
||||||
|
bye: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move to next battle
|
||||||
|
this._moveToNextBattle(lobby);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
return false; // Tournament continues
|
||||||
* Shuffle array in-place using Fisher-Yates algorithm
|
|
||||||
* @param {Array} array - Array to shuffle
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_shuffleArray(array) {
|
|
||||||
for (let i = array.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
|
||||||
[array[i], array[j]] = [array[j], array[i]];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -302,38 +302,69 @@ io.on('connection', (socket) => {
|
|||||||
// Submit a vote in a battle
|
// Submit a vote in a battle
|
||||||
socket.on('submit_vote', ({ songId }, callback) => {
|
socket.on('submit_vote', ({ songId }, callback) => {
|
||||||
try {
|
try {
|
||||||
|
console.log(`Player ${socket.id} voting for song ${songId}`);
|
||||||
const result = gameManager.submitVote(socket.id, songId);
|
const result = gameManager.submitVote(socket.id, songId);
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
|
if (callback) callback({ error: result.error });
|
||||||
|
} else {
|
||||||
|
// Broadcast updated game state to all players in the lobby
|
||||||
|
socket.to(result.lobbyId).emit('vote_submitted', result);
|
||||||
|
|
||||||
|
// Check if we've entered the BATTLE state (all votes are in and battle has ended)
|
||||||
|
const lobby = result.lobby;
|
||||||
|
if (lobby.state === 'BATTLE') {
|
||||||
|
// Broadcast battle result to all players in the lobby
|
||||||
|
io.to(result.lobbyId).emit('battle_ended', {
|
||||||
|
previousBattle: lobby.previousBattle,
|
||||||
|
state: 'BATTLE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Check if the game has finished (edge case - last battle with automatic winner)
|
||||||
|
else if (lobby.state === 'FINISHED') {
|
||||||
|
console.log('Game finished after vote submission');
|
||||||
|
io.to(result.lobbyId).emit('game_finished', {
|
||||||
|
winner: lobby.finalWinner,
|
||||||
|
battles: lobby.battles
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (callback) callback(result);
|
if (callback) callback(result);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
// Notify all players about vote count
|
console.error('Error submitting vote:', error);
|
||||||
io.to(result.lobbyId).emit('vote_submitted', {
|
if (callback) callback({ error: 'Server error while submitting vote' });
|
||||||
lobby: result.lobby
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// If battle is finished, notify about new battle
|
// Proceed to next battle after the battle result screen
|
||||||
if (result.lobby.currentBattle && result.lobby.currentBattle !== result.lobby.battles[result.lobby.battles.length - 1]) {
|
socket.on('proceed_to_next_battle', (data, callback) => {
|
||||||
io.to(result.lobbyId).emit('new_battle', {
|
try {
|
||||||
battle: result.lobby.currentBattle
|
const result = gameManager.proceedToNextBattle(socket.id);
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// If game is finished, notify about the winner
|
if (result.error) {
|
||||||
|
if (callback) callback({ error: result.error });
|
||||||
|
} else {
|
||||||
|
// Check if the game has finished
|
||||||
if (result.lobby.state === 'FINISHED') {
|
if (result.lobby.state === 'FINISHED') {
|
||||||
|
console.log('Game finished, broadcasting final winner');
|
||||||
io.to(result.lobbyId).emit('game_finished', {
|
io.to(result.lobbyId).emit('game_finished', {
|
||||||
winner: result.lobby.finalWinner,
|
winner: result.lobby.finalWinner,
|
||||||
battles: result.lobby.battles
|
battles: result.lobby.battles
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Broadcast updated game state to all players in the lobby
|
||||||
|
io.to(result.lobbyId).emit('new_battle', {
|
||||||
|
battle: result.lobby.currentBattle,
|
||||||
|
state: result.lobby.state
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send response to client
|
|
||||||
if (callback) callback(result);
|
if (callback) callback(result);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error submitting vote:', error);
|
console.error('Error proceeding to next battle:', error);
|
||||||
if (callback) callback({ error: 'Failed to submit vote' });
|
if (callback) callback({ error: 'Server error while proceeding to next battle' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user