Compare commits
7 Commits
e24ecb418c
...
main
Author | SHA1 | Date | |
---|---|---|---|
f3c87878ce | |||
8f91e27ca1 | |||
0c543a1a01 | |||
301e08b6e6 | |||
f2712bdcec | |||
9f4ebf379f | |||
4f4626260f |
@@ -7,6 +7,8 @@ import HomeScreen from "./components/HomeScreen";
|
||||
import SongSubmissionScreen from "./components/SongSubmissionScreen";
|
||||
import VotingScreen from "./components/VotingScreen";
|
||||
import ResultsScreen from "./components/ResultsScreen";
|
||||
import BattleResultScreen from "./components/BattleResultScreen";
|
||||
import ConnectionStatus from "./components/ConnectionStatus";
|
||||
|
||||
const App = () => {
|
||||
const [cursorPos, setCursorPos] = useState({x: 0, y: 0});
|
||||
@@ -51,6 +53,8 @@ const App = () => {
|
||||
return <VotingScreen />;
|
||||
case 'FINISHED':
|
||||
return <ResultsScreen />;
|
||||
case 'BATTLE':
|
||||
return <BattleResultScreen />;
|
||||
default:
|
||||
return <HomeScreen />;
|
||||
}
|
||||
@@ -84,12 +88,6 @@ const App = () => {
|
||||
</div>
|
||||
|
||||
<div className="content-container">
|
||||
{!isConnected && (
|
||||
<div className="connection-status">
|
||||
<p>Connecting to server...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
<p>{error}</p>
|
||||
@@ -97,6 +95,9 @@ const App = () => {
|
||||
)}
|
||||
|
||||
{renderGameScreen()}
|
||||
|
||||
{/* Show connection status component */}
|
||||
<ConnectionStatus />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
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
|
55
client/src/common/styles/components/connection-status.sass
Normal file
55
client/src/common/styles/components/connection-status.sass
Normal file
@@ -0,0 +1,55 @@
|
||||
// connection-status.sass - Component for displaying network connection status
|
||||
|
||||
.connection-status
|
||||
position: fixed
|
||||
bottom: 0
|
||||
left: 0
|
||||
right: 0
|
||||
padding: 0.5rem 1rem
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
gap: 0.5rem
|
||||
z-index: 1000
|
||||
transition: all 0.3s ease
|
||||
animation: slideUp 0.3s forwards
|
||||
font-family: 'Press Start 2P', monospace
|
||||
font-size: 0.7rem
|
||||
|
||||
&.connected
|
||||
background-color: rgba($success, 0.9)
|
||||
color: #fff
|
||||
animation: slideUp 0.3s forwards, fadeOut 0.5s 2.5s forwards
|
||||
|
||||
&.disconnected
|
||||
background-color: rgba(#f44336, 0.9)
|
||||
color: #fff
|
||||
|
||||
&.reconnecting
|
||||
background-color: rgba(#ff9800, 0.9)
|
||||
color: #fff
|
||||
|
||||
&.offline
|
||||
background-color: rgba(#f44336, 0.9)
|
||||
color: #fff
|
||||
|
||||
.connection-icon
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
|
||||
.connection-message
|
||||
text-align: center
|
||||
|
||||
@keyframes slideUp
|
||||
from
|
||||
transform: translateY(100%)
|
||||
to
|
||||
transform: translateY(0)
|
||||
|
||||
@keyframes fadeOut
|
||||
from
|
||||
opacity: 1
|
||||
to
|
||||
opacity: 0
|
||||
transform: translateY(100%)
|
@@ -179,13 +179,27 @@
|
||||
padding: 2rem
|
||||
width: 90%
|
||||
max-width: 500px
|
||||
border: 6px solid #000
|
||||
box-shadow: 10px 10px 0 rgba(0, 0, 0, 0.5)
|
||||
animation: modal-appear 0.3s ease-out
|
||||
|
||||
h2
|
||||
margin-top: 0
|
||||
color: $primary
|
||||
font-family: 'Press Start 2P', monospace
|
||||
font-size: 1.4rem
|
||||
margin-bottom: 1.5rem
|
||||
|
||||
.modal-actions
|
||||
display: flex
|
||||
justify-content: flex-end
|
||||
gap: 1rem
|
||||
margin-top: 2rem
|
||||
|
||||
@keyframes modal-appear
|
||||
0%
|
||||
transform: scale(0.8)
|
||||
opacity: 0
|
||||
100%
|
||||
transform: scale(1)
|
||||
opacity: 1
|
||||
|
@@ -243,6 +243,14 @@
|
||||
padding: 0.25rem 0.5rem
|
||||
border-radius: 1rem
|
||||
background-color: rgba(255, 255, 255, 0.1)
|
||||
margin-right: 0.5rem
|
||||
|
||||
.submitter
|
||||
display: block
|
||||
font-size: 0.8rem
|
||||
color: $text-muted
|
||||
margin-top: 0.5rem
|
||||
font-style: italic
|
||||
|
||||
.versus
|
||||
font-family: 'Bangers', cursive
|
||||
|
@@ -124,6 +124,12 @@
|
||||
color: $accent
|
||||
font-weight: bold
|
||||
text-align: center
|
||||
|
||||
.host-info
|
||||
margin-top: 0.5rem
|
||||
color: #fff
|
||||
font-style: italic
|
||||
font-size: 0.7rem
|
||||
|
||||
.video-container
|
||||
margin-top: 1rem
|
||||
@@ -247,11 +253,46 @@
|
||||
.voting-actions
|
||||
display: flex
|
||||
justify-content: center
|
||||
flex-direction: column
|
||||
align-items: center
|
||||
margin: 2rem 0
|
||||
|
||||
.btn
|
||||
min-width: 180px
|
||||
font-size: 1rem
|
||||
|
||||
&.offline
|
||||
background-color: $secondary
|
||||
position: relative
|
||||
|
||||
.offline-notice
|
||||
margin-top: 1rem
|
||||
padding: 0.5rem
|
||||
background-color: rgba($secondary, 0.2)
|
||||
border: 2px solid $secondary
|
||||
max-width: 400px
|
||||
text-align: center
|
||||
font-size: 0.7rem
|
||||
|
||||
svg
|
||||
margin-right: 0.5rem
|
||||
color: $secondary
|
||||
|
||||
.offline-vote-status
|
||||
margin-top: 1rem
|
||||
padding: 0.5rem
|
||||
background-color: rgba($success, 0.2)
|
||||
border: 2px solid $success
|
||||
max-width: 400px
|
||||
text-align: center
|
||||
font-size: 0.7rem
|
||||
color: $success
|
||||
animation: pulse-opacity 2s infinite
|
||||
|
||||
&.error
|
||||
background-color: rgba(#f44336, 0.2)
|
||||
border-color: #f44336
|
||||
color: #f44336
|
||||
|
||||
// Voting status and information
|
||||
.voting-status
|
||||
@@ -267,6 +308,27 @@
|
||||
font-family: 'Press Start 2P', monospace
|
||||
font-size: 0.9rem
|
||||
color: $text
|
||||
|
||||
.reconnecting-notice
|
||||
margin: 0.5rem auto 1rem auto
|
||||
padding: 0.5rem
|
||||
background-color: rgba($secondary, 0.2)
|
||||
border: 2px solid $secondary
|
||||
max-width: 400px
|
||||
text-align: center
|
||||
font-size: 0.7rem
|
||||
color: $secondary
|
||||
animation: pulse-opacity 2s infinite
|
||||
|
||||
.auto-advance-notice
|
||||
margin: 1rem auto
|
||||
max-width: 400px
|
||||
padding: 0.75rem
|
||||
background-color: rgba($secondary, 0.2)
|
||||
border: 2px solid $secondary
|
||||
text-align: center
|
||||
color: $text-muted
|
||||
font-style: italic
|
||||
|
||||
.votes-count
|
||||
display: flex
|
||||
@@ -281,6 +343,11 @@
|
||||
color: $primary
|
||||
font-weight: bold
|
||||
|
||||
.offline-badge
|
||||
color: $secondary
|
||||
margin-left: 0.5rem
|
||||
font-size: 0.7rem
|
||||
|
||||
// Player votes list styling
|
||||
.player-votes
|
||||
background-color: rgba(0, 0, 0, 0.2)
|
||||
@@ -352,6 +419,21 @@
|
||||
background-color: rgba(255, 255, 255, 0.05)
|
||||
border-color: rgba(255, 255, 255, 0.1)
|
||||
opacity: 0.7
|
||||
|
||||
&.offline-voted
|
||||
background-color: rgba($secondary, 0.1)
|
||||
border-color: rgba($secondary, 0.5)
|
||||
|
||||
.offline-icon
|
||||
color: $secondary
|
||||
margin-left: 0.5rem
|
||||
animation: pixel-pulse 1.5s infinite
|
||||
|
||||
@keyframes pulse-opacity
|
||||
0%, 100%
|
||||
opacity: 1
|
||||
50%
|
||||
opacity: 0.5
|
||||
|
||||
@keyframes pixel-blink
|
||||
0%, 100%
|
||||
@@ -365,3 +447,230 @@
|
||||
justify-content: center
|
||||
margin: 2rem 0
|
||||
animation: pixel-float 3s infinite ease-in-out
|
||||
|
||||
// Vote Progress Bar
|
||||
.vote-progress-container
|
||||
width: 100%
|
||||
margin-top: 1.5rem
|
||||
margin-bottom: 2rem
|
||||
padding: 0 0.5rem
|
||||
animation: fade-in-up 0.5s ease forwards
|
||||
position: relative
|
||||
|
||||
@keyframes fade-in-up
|
||||
from
|
||||
opacity: 0
|
||||
transform: translateY(10px)
|
||||
to
|
||||
opacity: 1
|
||||
transform: translateY(0)
|
||||
|
||||
.vote-progress-labels
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
margin-bottom: 0.5rem
|
||||
font-size: 0.8rem
|
||||
font-family: 'Press Start 2P', monospace
|
||||
|
||||
.vote-label
|
||||
padding: 0.5rem 0.75rem
|
||||
background-color: rgba(0, 0, 0, 0.7)
|
||||
border: 2px solid #000
|
||||
transform: translateY(0)
|
||||
transition: transform 0.3s ease
|
||||
position: relative
|
||||
z-index: 2
|
||||
|
||||
&.left
|
||||
color: $primary
|
||||
border-left-color: $primary
|
||||
border-top-color: $primary
|
||||
border-bottom-color: $primary
|
||||
box-shadow: -3px 3px 0 rgba($primary, 0.3)
|
||||
&:hover
|
||||
transform: translateY(-3px)
|
||||
box-shadow: -5px 5px 0 rgba($primary, 0.5)
|
||||
|
||||
&.right
|
||||
color: $accent
|
||||
border-right-color: $accent
|
||||
border-top-color: $accent
|
||||
border-bottom-color: $accent
|
||||
box-shadow: 3px 3px 0 rgba($accent, 0.3)
|
||||
&:hover
|
||||
transform: translateY(-3px)
|
||||
box-shadow: 5px 5px 0 rgba($accent, 0.5)
|
||||
|
||||
&.winning
|
||||
background-color: rgba(#000, 0.8)
|
||||
border-width: 3px
|
||||
transform: translateY(-3px)
|
||||
position: relative
|
||||
z-index: 3
|
||||
animation: winner-pulse 1.5s infinite alternate
|
||||
|
||||
&.left
|
||||
box-shadow: -5px 5px 0 rgba($primary, 0.7), 0 0 15px rgba($primary, 0.7)
|
||||
|
||||
&.right
|
||||
box-shadow: 5px 5px 0 rgba($accent, 0.7), 0 0 15px rgba($accent, 0.7)
|
||||
|
||||
&.landslide
|
||||
transform: translateY(-5px)
|
||||
font-weight: bold
|
||||
animation: winner-landslide-pulse 1s infinite alternate
|
||||
|
||||
&.left
|
||||
box-shadow: -6px 6px 0 rgba($primary, 0.7), 0 0 25px rgba($primary, 0.9)
|
||||
|
||||
&.right
|
||||
box-shadow: 6px 6px 0 rgba($accent, 0.7), 0 0 25px rgba($accent, 0.9)
|
||||
|
||||
@keyframes winner-landslide-pulse
|
||||
from
|
||||
box-shadow: -6px 6px 0 rgba($primary, 0.7), 0 0 25px rgba($primary, 0.9)
|
||||
to
|
||||
box-shadow: -6px 6px 0 rgba($primary, 0.7), 0 0 40px rgba($primary, 1)
|
||||
|
||||
.vote-progress-bar
|
||||
height: 36px
|
||||
background-color: #222
|
||||
border: 4px solid #000
|
||||
position: relative
|
||||
overflow: hidden
|
||||
display: flex
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3)
|
||||
|
||||
.vote-progress-fill
|
||||
height: 100%
|
||||
transition: width 1.2s cubic-bezier(0.34, 1.56, 0.64, 1)
|
||||
position: relative
|
||||
min-width: 0
|
||||
|
||||
&.song1
|
||||
background: linear-gradient(to right, darken($primary, 20%), $primary)
|
||||
box-shadow: 0 8px 24px rgba($primary, 0.3), 0 0 10px rgba($primary, 0.2)
|
||||
animation: pulse-song1 2s infinite alternate
|
||||
|
||||
&.song2
|
||||
background: linear-gradient(to left, darken($accent, 20%), $accent)
|
||||
box-shadow: 0 8px 24px rgba($accent, 0.3), 0 0 10px rgba($accent, 0.2)
|
||||
animation: pulse-song2 2s infinite alternate
|
||||
|
||||
.vote-progress-decoration
|
||||
position: absolute
|
||||
top: 0
|
||||
bottom: 0
|
||||
width: 20px
|
||||
background-size: 4px 4px
|
||||
opacity: 0.6
|
||||
|
||||
&.left
|
||||
right: 10px
|
||||
background-image: linear-gradient(45deg, rgba(255,255,255,0.4) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.4) 50%, rgba(255,255,255,0.4) 75%, transparent 75%, transparent)
|
||||
animation: move-stripes-left 20s linear infinite
|
||||
|
||||
&.right
|
||||
left: 10px
|
||||
background-image: linear-gradient(-45deg, rgba(255,255,255,0.4) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.4) 50%, rgba(255,255,255,0.4) 75%, transparent 75%, transparent)
|
||||
animation: move-stripes-right 20s linear infinite
|
||||
|
||||
&.landslide
|
||||
border-width: 5px
|
||||
animation: landslide-border-pulse 1.5s infinite alternate
|
||||
|
||||
&.song1
|
||||
border-color: lighten($primary, 20%)
|
||||
|
||||
&.song2
|
||||
border-color: lighten($accent, 20%)
|
||||
|
||||
.vote-progress-divider
|
||||
position: absolute
|
||||
top: -8px
|
||||
bottom: -8px
|
||||
width: 6px
|
||||
background-color: #fff
|
||||
transform: translateX(-50%)
|
||||
z-index: 10
|
||||
box-shadow: 0 0 15px rgba(255, 255, 255, 0.7), 0 0 5px rgba(0, 0, 0, 0.5)
|
||||
transition: left 1.2s cubic-bezier(0.34, 1.56, 0.64, 1)
|
||||
&:before, &:after
|
||||
content: ''
|
||||
position: absolute
|
||||
left: 50%
|
||||
width: 12px
|
||||
height: 12px
|
||||
background-color: #fff
|
||||
transform: translateX(-50%) rotate(45deg)
|
||||
box-shadow: 0 0 10px rgba(255, 255, 255, 0.7), 0 0 3px rgba(0, 0, 0, 0.5)
|
||||
|
||||
&:before
|
||||
top: 0
|
||||
|
||||
&:after
|
||||
bottom: 0
|
||||
|
||||
.vote-progress-percentages
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
margin-top: 0.75rem
|
||||
font-family: 'Press Start 2P', monospace
|
||||
font-size: 1rem
|
||||
|
||||
.vote-percent
|
||||
font-weight: bold
|
||||
text-shadow: 2px 2px 0 #000
|
||||
padding: 0.4rem 0.75rem
|
||||
background-color: rgba(0, 0, 0, 0.7)
|
||||
border: 2px solid #000
|
||||
letter-spacing: 1px
|
||||
position: relative
|
||||
|
||||
&.left
|
||||
color: $primary
|
||||
border-left-color: $primary
|
||||
border-bottom-color: $primary
|
||||
transform: skew(-10deg)
|
||||
box-shadow: -3px 3px 0 rgba($primary, 0.4)
|
||||
|
||||
&.right
|
||||
color: $accent
|
||||
border-right-color: $accent
|
||||
border-bottom-color: $accent
|
||||
transform: skew(10deg)
|
||||
box-shadow: 3px 3px 0 rgba($accent, 0.4)
|
||||
|
||||
&.winning
|
||||
font-size: 1.2rem
|
||||
font-weight: bolder
|
||||
animation: winner-text-pulse 2s infinite alternate
|
||||
|
||||
&.left
|
||||
box-shadow: -5px 5px 0 rgba($primary, 0.5), 0 0 10px rgba($primary, 0.5)
|
||||
|
||||
&.right
|
||||
box-shadow: 5px 5px 0 rgba($accent, 0.5), 0 0 10px rgba($accent, 0.5)
|
||||
|
||||
&.landslide
|
||||
font-size: 1.3rem
|
||||
animation: landslide-text-pulse 1s infinite alternate
|
||||
|
||||
&.left
|
||||
box-shadow: -6px 6px 0 rgba($primary, 0.6), 0 0 15px rgba($primary, 0.6)
|
||||
|
||||
&.right
|
||||
box-shadow: 6px 6px 0 rgba($accent, 0.6), 0 0 15px rgba($accent, 0.6)
|
||||
|
||||
@keyframes landslide-text-pulse
|
||||
from
|
||||
text-shadow: 2px 2px 0 #000, 0 0 10px currentColor
|
||||
to
|
||||
text-shadow: 2px 2px 0 #000, 0 0 25px currentColor, 0 0 5px #fff
|
||||
|
||||
// Bye container for automatic advances
|
||||
.bye-container
|
||||
display: flex
|
||||
justify-content: center
|
||||
margin: 2rem 0
|
||||
animation: pixel-float 3s infinite ease-in-out
|
||||
|
@@ -53,17 +53,26 @@
|
||||
border: 3px solid #000
|
||||
position: relative
|
||||
cursor: pointer
|
||||
box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.2)
|
||||
transition: all 0.2s ease
|
||||
|
||||
&:checked:after
|
||||
content: ''
|
||||
position: absolute
|
||||
left: 6px
|
||||
top: 2px
|
||||
width: 6px
|
||||
height: 12px
|
||||
border: solid $primary
|
||||
border-width: 0 3px 3px 0
|
||||
transform: rotate(45deg)
|
||||
&:hover
|
||||
border-color: $secondary
|
||||
|
||||
&:checked
|
||||
background-color: rgba($primary, 0.2)
|
||||
border-color: $primary
|
||||
|
||||
&:after
|
||||
content: ''
|
||||
position: absolute
|
||||
left: 6px
|
||||
top: 2px
|
||||
width: 6px
|
||||
height: 12px
|
||||
border: solid $primary
|
||||
border-width: 0 3px 3px 0
|
||||
transform: rotate(45deg)
|
||||
|
||||
&.checkbox
|
||||
display: flex
|
||||
@@ -86,6 +95,31 @@
|
||||
gap: 1rem
|
||||
margin-top: 1.5rem
|
||||
|
||||
// Checkbox group with helper text
|
||||
.checkbox-group
|
||||
display: flex
|
||||
flex-direction: column
|
||||
margin-bottom: 1.5rem
|
||||
|
||||
.checkbox-container
|
||||
display: flex
|
||||
align-items: center
|
||||
margin-bottom: 0.5rem
|
||||
|
||||
input[type="checkbox"]
|
||||
margin-right: 0.75rem
|
||||
min-width: 24px
|
||||
height: 24px
|
||||
|
||||
label
|
||||
margin-bottom: 0
|
||||
margin-top: 0
|
||||
cursor: pointer
|
||||
font-family: 'Press Start 2P', monospace
|
||||
font-size: 0.8rem
|
||||
text-transform: uppercase
|
||||
color: $secondary
|
||||
|
||||
// Buttons
|
||||
.btn
|
||||
display: inline-flex
|
||||
|
@@ -9,9 +9,11 @@
|
||||
@import './components/song-submission-screen'
|
||||
@import './components/voting-screen'
|
||||
@import './components/results-screen'
|
||||
@import './components/battle-result-screen'
|
||||
@import './components/youtube-embed'
|
||||
@import './components/youtube-search'
|
||||
@import './components/song-form-overlay'
|
||||
@import './components/connection-status'
|
||||
|
||||
// Global styles
|
||||
html, body
|
||||
@@ -416,6 +418,7 @@ h1, h2, h3
|
||||
justify-content: center
|
||||
align-items: center
|
||||
cursor: pointer
|
||||
color: white
|
||||
transition: all 0.2s ease
|
||||
|
||||
&:hover
|
||||
|
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;
|
70
client/src/components/ConnectionStatus.jsx
Normal file
70
client/src/components/ConnectionStatus.jsx
Normal file
@@ -0,0 +1,70 @@
|
||||
// ConnectionStatus.jsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSocket } from '../context/GameContext';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faWifi, faExclamationTriangle, faSpinner } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
function ConnectionStatus() {
|
||||
const { socket, isConnected, isReconnecting, reconnectionAttempts } = useSocket();
|
||||
const [showStatus, setShowStatus] = useState(false);
|
||||
const [offline, setOffline] = useState(!navigator.onLine);
|
||||
|
||||
// Monitor browser's online/offline status
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setOffline(false);
|
||||
const handleOffline = () => setOffline(true);
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Always show status when disconnected, hide after successful connection
|
||||
useEffect(() => {
|
||||
if (!isConnected || isReconnecting || offline) {
|
||||
setShowStatus(true);
|
||||
} else {
|
||||
// Hide the status indicator after a delay
|
||||
const timer = setTimeout(() => {
|
||||
setShowStatus(false);
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isConnected, isReconnecting, offline]);
|
||||
|
||||
if (!showStatus) return null;
|
||||
|
||||
let statusClass = 'connection-status';
|
||||
let icon, message;
|
||||
|
||||
if (offline) {
|
||||
statusClass += ' offline';
|
||||
icon = <FontAwesomeIcon icon={faExclamationTriangle} />;
|
||||
message = 'Keine Internetverbindung. Spielstand wird lokal gespeichert.';
|
||||
} else if (!isConnected) {
|
||||
statusClass += ' disconnected';
|
||||
icon = <FontAwesomeIcon icon={faExclamationTriangle} />;
|
||||
message = 'Verbindung zum Server verloren. Versuche neu zu verbinden...';
|
||||
} else if (isReconnecting) {
|
||||
statusClass += ' reconnecting';
|
||||
icon = <FontAwesomeIcon icon={faSpinner} spin />;
|
||||
message = `Verbindungsversuch ${reconnectionAttempts}...`;
|
||||
} else {
|
||||
statusClass += ' connected';
|
||||
icon = <FontAwesomeIcon icon={faWifi} />;
|
||||
message = 'Verbunden mit dem Server';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={statusClass}>
|
||||
<div className="connection-icon">{icon}</div>
|
||||
<div className="connection-message">{message}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConnectionStatus;
|
@@ -89,7 +89,7 @@ const HomeScreen = () => {
|
||||
</div>
|
||||
|
||||
<footer className="home-footer">
|
||||
<p>ich weiß doch auch nicht</p>
|
||||
<p>ik wes doch och nüsch</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
@@ -2,13 +2,15 @@ import { useState, useEffect } from 'react';
|
||||
import { useGame } from '../context/GameContext';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faUsers, faGear, faPlay, faCopy, faSignOutAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import { getDisplayName } from '../utils/playerUtils';
|
||||
|
||||
const LobbyScreen = () => {
|
||||
const { lobby, currentPlayer, isHost, updateSettings, startGame, leaveLobby } = useGame();
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [settings, setSettings] = useState({
|
||||
songsPerPlayer: 4,
|
||||
maxPlayers: 10
|
||||
maxPlayers: 10,
|
||||
hidePlayerNames: false
|
||||
});
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
@@ -64,9 +66,11 @@ const LobbyScreen = () => {
|
||||
<div className="players-list">
|
||||
<h2><FontAwesomeIcon icon={faUsers} /> Spieler ({lobby.players.length})</h2>
|
||||
<ul>
|
||||
{lobby.players.map(player => (
|
||||
{lobby.players.map((player, index) => (
|
||||
<li key={player.id} className={`player ${!player.isConnected ? 'disconnected' : ''} ${player.id === lobby.hostId ? 'host' : ''}`}>
|
||||
{player.name} {player.id === lobby.hostId && '(Gastgeber)'} {player.id === currentPlayer.id && '(Du)'}
|
||||
{getDisplayName(player, lobby, currentPlayer, index)}
|
||||
{player.id === lobby.hostId && ' (Gastgeber)'}
|
||||
{player.id === currentPlayer.id && !lobby.settings.hidePlayerNames && ' (Du)'}
|
||||
{!player.isConnected && <span className="status-disconnected">(Getrennt)</span>}
|
||||
</li>
|
||||
))}
|
||||
@@ -78,6 +82,7 @@ const LobbyScreen = () => {
|
||||
<h3><FontAwesomeIcon icon={faGear} /> Spieleinstellungen</h3>
|
||||
<p>Lieder pro Spieler: {settings.songsPerPlayer}</p>
|
||||
<p>Maximale Spieler: {settings.maxPlayers}</p>
|
||||
<p>Spielernamen verbergen: {settings.hidePlayerNames ? 'Ja' : 'Nein'}</p>
|
||||
|
||||
{isHost && (
|
||||
<button className="btn secondary" onClick={() => setShowSettings(true)}>
|
||||
@@ -138,12 +143,25 @@ const LobbyScreen = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group checkbox-group">
|
||||
<div className="checkbox-container">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="hidePlayerNames"
|
||||
name="hidePlayerNames"
|
||||
checked={settings.hidePlayerNames}
|
||||
onChange={handleSettingsChange}
|
||||
/>
|
||||
<label htmlFor="hidePlayerNames">Spielernamen verbergen</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="btn secondary" onClick={() => setShowSettings(false)}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button className="btn primary" onClick={handleSaveSettings}>
|
||||
Einstellungen speichern
|
||||
Mach rin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -3,48 +3,56 @@ import { useGame } from '../context/GameContext';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faTrophy, faHome, faRedo, faChartLine } from '@fortawesome/free-solid-svg-icons';
|
||||
import YouTubeEmbed from './YouTubeEmbed';
|
||||
import { getDisplayName } from '../utils/playerUtils';
|
||||
|
||||
const ResultsScreen = () => {
|
||||
const { lobby, currentPlayer, leaveLobby } = useGame();
|
||||
const [showBattleHistory, setShowBattleHistory] = useState(false);
|
||||
const [confetti, setConfetti] = useState(true);
|
||||
|
||||
// Winner information
|
||||
// Gewinner-Informationen
|
||||
const winner = lobby?.finalWinner;
|
||||
const winnerVideoId = getYouTubeId(winner?.youtubeLink);
|
||||
|
||||
// Confetti effect for winner celebration
|
||||
// Konfetti-Effekt für die Siegerfeier
|
||||
useEffect(() => {
|
||||
if (confetti) {
|
||||
// Create confetti animation
|
||||
// Konfetti-Animation erstellen
|
||||
const createConfetti = () => {
|
||||
const confettiContainer = document.createElement('div');
|
||||
confettiContainer.className = 'confetti';
|
||||
|
||||
// Random position and color
|
||||
// Zufällige Position, Größe und Farbe
|
||||
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.7 + 0.3; // Zwischen 0.3 und 1rem
|
||||
const rotation = Math.random() * 360; // Zufällige Rotation
|
||||
const duration = Math.random() * 3 + 2; // Zwischen 2 und 5 Sekunden
|
||||
|
||||
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('.results-screen').appendChild(confettiContainer);
|
||||
|
||||
// Remove after animation
|
||||
// Nach Animation entfernen
|
||||
setTimeout(() => {
|
||||
confettiContainer.remove();
|
||||
}, 5000);
|
||||
}, duration * 1000);
|
||||
};
|
||||
|
||||
// Create multiple confetti pieces
|
||||
// Mehrere Konfetti-Stücke erstellen
|
||||
const confettiInterval = setInterval(() => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
createConfetti();
|
||||
}
|
||||
}, 300);
|
||||
}, 200);
|
||||
|
||||
// Stop confetti after some time
|
||||
// Konfetti nach einiger Zeit stoppen
|
||||
setTimeout(() => {
|
||||
clearInterval(confettiInterval);
|
||||
setConfetti(false);
|
||||
@@ -56,7 +64,7 @@ const ResultsScreen = () => {
|
||||
}
|
||||
}, [confetti]);
|
||||
|
||||
// Get YouTube video ID from link
|
||||
// YouTube-Video-ID aus Link extrahieren
|
||||
function getYouTubeId(url) {
|
||||
if (!url) return null;
|
||||
|
||||
@@ -66,25 +74,38 @@ const ResultsScreen = () => {
|
||||
return (match && match[2].length === 11) ? match[2] : null;
|
||||
}
|
||||
|
||||
// Find the submitter from the player list
|
||||
const findSubmitter = (submitterId) => {
|
||||
if (!lobby || !lobby.players) return null;
|
||||
return lobby.players.find(player => player.id === submitterId);
|
||||
};
|
||||
|
||||
if (!winner) {
|
||||
return (
|
||||
<div className="results-screen">
|
||||
<h2>Calculating results...</h2>
|
||||
<h2>Ergebnisse werden berechnet...</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Find the player who submitted the winning song
|
||||
const submitter = findSubmitter(winner.submittedBy);
|
||||
|
||||
return (
|
||||
<div className="results-screen">
|
||||
<header>
|
||||
<h1><FontAwesomeIcon icon={faTrophy} /> Winner!</h1>
|
||||
<h1><FontAwesomeIcon icon={faTrophy} /> Gewinner!</h1>
|
||||
</header>
|
||||
|
||||
<div className="winner-card">
|
||||
<div className="winner-info">
|
||||
<h2>{winner.title}</h2>
|
||||
<h3>by {winner.artist}</h3>
|
||||
<p className="submitter">Submitted by: {winner.submittedByName}</p>
|
||||
<h3>von {winner.artist}</h3>
|
||||
<p className="submitter">
|
||||
Eingereicht von: {submitter
|
||||
? getDisplayName(submitter, lobby, currentPlayer)
|
||||
: (lobby?.settings?.hidePlayerNames ? 'Anonymer Spieler' : winner.submittedByName)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{winnerVideoId ? (
|
||||
@@ -104,17 +125,17 @@ const ResultsScreen = () => {
|
||||
onClick={() => setShowBattleHistory(!showBattleHistory)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faChartLine} />
|
||||
{showBattleHistory ? 'Hide Battle History' : 'Show Battle History'}
|
||||
{showBattleHistory ? 'Kampfverlauf ausblenden' : 'Kampfverlauf anzeigen'}
|
||||
</button>
|
||||
|
||||
<button className="btn primary" onClick={leaveLobby}>
|
||||
<FontAwesomeIcon icon={faHome} /> Return to Home
|
||||
<FontAwesomeIcon icon={faHome} /> Zurück zum Start
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showBattleHistory && (
|
||||
<div className="battle-history">
|
||||
<h3>Battle History</h3>
|
||||
<h3>Kampfverlauf</h3>
|
||||
|
||||
<div className="battles-list">
|
||||
{lobby?.battles?.map((battle, index) => {
|
||||
@@ -122,13 +143,17 @@ const ResultsScreen = () => {
|
||||
const song1VideoId = getYouTubeId(battle.song1?.youtubeLink);
|
||||
const song2VideoId = battle.song2 ? getYouTubeId(battle.song2.youtubeLink) : null;
|
||||
|
||||
// Handle bye rounds
|
||||
// Find the players who submitted the songs
|
||||
const song1Submitter = findSubmitter(battle.song1?.submittedBy);
|
||||
const song2Submitter = battle.song2 ? findSubmitter(battle.song2?.submittedBy) : null;
|
||||
|
||||
// Freilos-Runden behandeln
|
||||
const isByeRound = battle.bye === true || !battle.song2;
|
||||
|
||||
return (
|
||||
<div key={index} className="battle-item">
|
||||
<div className="battle-header">
|
||||
<h4>Round {battle.round + 1}, Battle {index + 1} {isByeRound ? "(Automatic Advance)" : ""}</h4>
|
||||
<h4>Runde {battle.round + 1}, Kampf {index + 1} {isByeRound ? "(Automatisches Weiterkommen)" : ""}</h4>
|
||||
</div>
|
||||
|
||||
<div className="battle-songs">
|
||||
@@ -136,19 +161,42 @@ const ResultsScreen = () => {
|
||||
<div className="song-info">
|
||||
<h5>{battle.song1.title}</h5>
|
||||
<p>{battle.song1.artist}</p>
|
||||
<span className="votes">{battle.song1Votes} votes</span>
|
||||
<span className="votes">{battle.song1Votes} Stimmen</span>
|
||||
{!lobby?.settings?.hidePlayerNames && (
|
||||
<span className="submitter">
|
||||
Eingereicht von: {song1Submitter
|
||||
? getDisplayName(song1Submitter, lobby, currentPlayer)
|
||||
: battle.song1?.submittedByName || 'Unbekannt'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="versus">VS</div>
|
||||
<div className="versus">{isByeRound ? 'FREILOS' : 'VS'}</div>
|
||||
|
||||
<div className={`battle-song ${!isWinner ? 'winner' : ''}`}>
|
||||
<div className="song-info">
|
||||
<h5>{battle.song2.title}</h5>
|
||||
<p>{battle.song2.artist}</p>
|
||||
<span className="votes">{battle.song2Votes} votes</span>
|
||||
{battle.song2 ? (
|
||||
<div className={`battle-song ${!isWinner ? 'winner' : ''}`}>
|
||||
<div className="song-info">
|
||||
<h5>{battle.song2.title}</h5>
|
||||
<p>{battle.song2.artist}</p>
|
||||
<span className="votes">{battle.song2Votes} Stimmen</span>
|
||||
{!lobby?.settings?.hidePlayerNames && (
|
||||
<span className="submitter">
|
||||
Eingereicht von: {song2Submitter
|
||||
? getDisplayName(song2Submitter, lobby, currentPlayer)
|
||||
: battle.song2?.submittedByName || 'Unbekannt'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="battle-song bye">
|
||||
<div className="song-info">
|
||||
<h5>Automatisches Freilos</h5>
|
||||
<p>Kein Gegner</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { useGame } from '../context/GameContext';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPlus, faTrash, faCheck, faMusic, faVideoCamera, faSearch, faSpinner, faExternalLinkAlt, faTimes } from '@fortawesome/free-solid-svg-icons';
|
||||
import { getDisplayName } from '../utils/playerUtils';
|
||||
|
||||
const SongSubmissionScreen = () => {
|
||||
const { lobby, currentPlayer, addSong, removeSong, setPlayerReady, searchYouTube, getYouTubeMetadata } = useGame();
|
||||
@@ -352,11 +353,15 @@ const SongSubmissionScreen = () => {
|
||||
|
||||
{canSubmitMoreSongs && !isFormVisible && !isReady && (
|
||||
<button
|
||||
className="add-song-btn"
|
||||
className="btn add-song-btn"
|
||||
onClick={() => setIsFormVisible(true)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} />
|
||||
<FontAwesomeIcon icon={faPlus} className="icon-margin" />
|
||||
<span>Lied hinzufügen</span>
|
||||
<span className="pixel-corner tl"></span>
|
||||
<span className="pixel-corner tr"></span>
|
||||
<span className="pixel-corner bl"></span>
|
||||
<span className="pixel-corner br"></span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -498,9 +503,9 @@ const SongSubmissionScreen = () => {
|
||||
<div className="player-status">
|
||||
<h4>Spieler bereit</h4>
|
||||
<ul className="players-ready-list">
|
||||
{lobby && lobby.players.map(player => (
|
||||
{lobby && lobby.players.map((player, index) => (
|
||||
<li key={player.id} className={player.isReady ? 'ready' : 'not-ready'}>
|
||||
{player.name} {player.id === currentPlayer.id && '(Du)'}
|
||||
{getDisplayName(player, lobby, currentPlayer, index)} {player.id === currentPlayer.id && lobby.settings.hidePlayerNames && '(Du)'}
|
||||
{player.isReady ? (
|
||||
<FontAwesomeIcon icon={faCheck} className="ready-icon" />
|
||||
) : (
|
||||
|
@@ -2,14 +2,17 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useGame } from '../context/GameContext';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faVoteYea, faTrophy, faMusic, faCheck, faMedal, faCrown } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faVoteYea, faTrophy, faMusic, faCheck, faMedal, faCrown, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
|
||||
import YouTubeEmbed from './YouTubeEmbed';
|
||||
import { getDisplayName } from '../utils/playerUtils';
|
||||
|
||||
function VotingScreen() {
|
||||
const { lobby, currentPlayer, submitVote } = useGame();
|
||||
const { lobby, currentPlayer, submitVote, isHost, isOffline, isReconnecting } = useGame();
|
||||
const [hasVoted, setHasVoted] = useState(false);
|
||||
const [selectedSong, setSelectedSong] = useState(null);
|
||||
const [countdown, setCountdown] = useState(null);
|
||||
const [processingByeAdvance, setProcessingByeAdvance] = useState(false);
|
||||
const [offlineVoteStatus, setOfflineVoteStatus] = useState(null);
|
||||
|
||||
// Hole aktuellen Kampf
|
||||
const battle = lobby?.currentBattle || null;
|
||||
@@ -34,14 +37,35 @@ function VotingScreen() {
|
||||
return 'Vorrunde';
|
||||
}, [lobby, battle]);
|
||||
|
||||
// Berechne das Verhältnis der Stimmen für den Fortschrittsbalken
|
||||
const voteRatio = useMemo(() => {
|
||||
if (!battle || !hasVoted) return { song1Percent: 50, song2Percent: 50 };
|
||||
|
||||
const totalVotes = (battle.song1Votes || 0) + (battle.song2Votes || 0);
|
||||
|
||||
if (totalVotes === 0) return { song1Percent: 50, song2Percent: 50 };
|
||||
|
||||
const song1Percent = Math.round((battle.song1Votes / totalVotes) * 100);
|
||||
const song2Percent = 100 - song1Percent;
|
||||
|
||||
// Determine winner (for styling purposes)
|
||||
const winner = song1Percent > song2Percent ? 'song1' : song1Percent < song2Percent ? 'song2' : 'tie';
|
||||
|
||||
// Calculate margin of victory for animation intensity
|
||||
const margin = Math.abs(song1Percent - song2Percent);
|
||||
const isLandslide = margin >= 30;
|
||||
|
||||
return { song1Percent, song2Percent, winner, totalVotes, isLandslide, margin };
|
||||
}, [battle, hasVoted]);
|
||||
|
||||
// Prüfe, ob der Spieler bereits abgestimmt hat
|
||||
useEffect(() => {
|
||||
if (battle && battle.votes && currentPlayer) {
|
||||
// Prüfe, ob die ID des Spielers im Stimmen-Objekt existiert
|
||||
// Da Stimmen als Objekt, nicht als Map gesendet werden
|
||||
const votesObj = battle.votes || {};
|
||||
setHasVoted(Object.prototype.hasOwnProperty.call(votesObj, currentPlayer.id) ||
|
||||
Object.keys(votesObj).includes(currentPlayer.id));
|
||||
setHasVoted(
|
||||
Object.prototype.hasOwnProperty.call(battle.votes, currentPlayer.id) ||
|
||||
battle.votes[currentPlayer.id] !== undefined
|
||||
);
|
||||
} else {
|
||||
setHasVoted(false);
|
||||
}
|
||||
@@ -58,8 +82,65 @@ function VotingScreen() {
|
||||
const handleSubmitVote = async () => {
|
||||
if (!selectedSong || hasVoted) return;
|
||||
|
||||
await submitVote(selectedSong);
|
||||
// setHasVoted wird jetzt durch den useEffect behandelt, der die Stimmen prüft
|
||||
try {
|
||||
// If offline, show status but still try to submit (it will be queued)
|
||||
if (isOffline) {
|
||||
setOfflineVoteStatus('pending');
|
||||
}
|
||||
|
||||
await submitVote(selectedSong);
|
||||
|
||||
if (isOffline) {
|
||||
// In offline mode, optimistically update UI
|
||||
setHasVoted(true);
|
||||
setOfflineVoteStatus('queued');
|
||||
|
||||
// Store vote locally for later sync
|
||||
try {
|
||||
const savedVotes = JSON.parse(localStorage.getItem('pendingVotes') || '{}');
|
||||
|
||||
if (battle) {
|
||||
savedVotes[`battle_${battle.round}_${battle.song1.id}`] = {
|
||||
songId: selectedSong,
|
||||
battleRound: battle.round,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
localStorage.setItem('pendingVotes', JSON.stringify(savedVotes));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Fehler beim Speichern der Offline-Stimme:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// setHasVoted wird jetzt durch den useEffect behandelt, der die Stimmen prüft
|
||||
} catch (error) {
|
||||
console.error("Fehler bei der Stimmabgabe:", error);
|
||||
setOfflineVoteStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle bye round advancement - für automatisches Weiterkommen
|
||||
const handleByeAdvance = async () => {
|
||||
if (processingByeAdvance || !isHost) return;
|
||||
|
||||
setProcessingByeAdvance(true);
|
||||
try {
|
||||
// If offline, show notification that the action will be queued
|
||||
if (isOffline) {
|
||||
setOfflineVoteStatus('byeQueued');
|
||||
}
|
||||
|
||||
// Nur der Host kann im Bye-Modus weiterschalten
|
||||
if (battle && battle.song1 && !battle.song2 && battle.song1.id) {
|
||||
await submitVote(battle.song1.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Fortfahren:", error);
|
||||
setOfflineVoteStatus('error');
|
||||
} finally {
|
||||
// Verzögerung, um mehrere Klicks zu verhindern
|
||||
setTimeout(() => setProcessingByeAdvance(false), 1000);
|
||||
}
|
||||
};
|
||||
|
||||
// Hole YouTube-Video-IDs aus Links
|
||||
@@ -103,6 +184,7 @@ function VotingScreen() {
|
||||
<p>{battle.song1.artist}</p>
|
||||
<div className="auto-advance-notice">
|
||||
<p>Dieses Lied kommt automatisch in die nächste Runde!</p>
|
||||
<p className="host-info">{isHost ? 'Du kannst als Host zum nächsten Kampf weitergehen.' : 'Warte auf den Host, um fortzufahren.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -120,13 +202,21 @@ function VotingScreen() {
|
||||
</div>
|
||||
|
||||
<div className="voting-status">
|
||||
<button className="btn primary pixelated full-width" onClick={() => submitVote(battle.song1.id)}>
|
||||
Weiter zum nächsten Kampf
|
||||
<span className="pixel-corner tl"></span>
|
||||
<span className="pixel-corner tr"></span>
|
||||
<span className="pixel-corner bl"></span>
|
||||
<span className="pixel-corner br"></span>
|
||||
</button>
|
||||
{isHost ? (
|
||||
<button
|
||||
className={`btn primary pixelated full-width ${processingByeAdvance ? 'disabled' : ''}`}
|
||||
onClick={handleByeAdvance}
|
||||
disabled={processingByeAdvance}
|
||||
>
|
||||
{processingByeAdvance ? 'Wird geladen...' : 'Mach weiter'}
|
||||
<span className="pixel-corner tl"></span>
|
||||
<span className="pixel-corner tr"></span>
|
||||
<span className="pixel-corner bl"></span>
|
||||
<span className="pixel-corner br"></span>
|
||||
</button>
|
||||
) : (
|
||||
<p className="auto-advance-notice">Warte auf den Host, um fortzufahren...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -227,22 +317,99 @@ function VotingScreen() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vote Progress Bar - Only show after voting */}
|
||||
{hasVoted && (
|
||||
<div className="vote-progress-container">
|
||||
<div className="vote-progress-labels">
|
||||
<span className={`vote-label left ${voteRatio.winner === 'song1' ? 'winning' : ''} ${voteRatio.winner === 'song1' && voteRatio.isLandslide ? 'landslide' : ''}`}>
|
||||
{battle.song1Votes || 0} Stimmen
|
||||
{voteRatio.winner === 'song1' && voteRatio.totalVotes > 1 &&
|
||||
<span className="winner-crown"><FontAwesomeIcon icon={faCrown} /></span>
|
||||
}
|
||||
</span>
|
||||
<span className={`vote-label right ${voteRatio.winner === 'song2' ? 'winning' : ''} ${voteRatio.winner === 'song2' && voteRatio.isLandslide ? 'landslide' : ''}`}>
|
||||
{battle.song2Votes || 0} Stimmen
|
||||
{voteRatio.winner === 'song2' && voteRatio.totalVotes > 1 &&
|
||||
<span className="winner-crown"><FontAwesomeIcon icon={faCrown} /></span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`vote-progress-bar ${voteRatio.winner !== 'tie' ? voteRatio.winner : ''} ${voteRatio.isLandslide ? 'landslide' : ''}`}>
|
||||
<div
|
||||
className={`vote-progress-fill song1 ${voteRatio.winner === 'song1' ? 'winning' : ''}`}
|
||||
style={{ width: `${voteRatio.song1Percent}%` }}
|
||||
>
|
||||
{voteRatio.song1Percent > 5 && (
|
||||
<div className="vote-progress-decoration left"></div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`vote-progress-divider ${voteRatio.margin <= 10 ? 'close-race' : ''}`}
|
||||
style={{ left: `${voteRatio.song1Percent}%` }}
|
||||
></div>
|
||||
<div
|
||||
className={`vote-progress-fill song2 ${voteRatio.winner === 'song2' ? 'winning' : ''}`}
|
||||
style={{ width: `${voteRatio.song2Percent}%` }}
|
||||
>
|
||||
{voteRatio.song2Percent > 5 && (
|
||||
<div className="vote-progress-decoration right"></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="vote-progress-percentages">
|
||||
<span className={`vote-percent left ${voteRatio.winner === 'song1' ? 'winning' : ''} ${voteRatio.winner === 'song1' && voteRatio.isLandslide ? 'landslide' : ''}`}>
|
||||
{voteRatio.song1Percent}%
|
||||
</span>
|
||||
<span className={`vote-percent right ${voteRatio.winner === 'song2' ? 'winning' : ''} ${voteRatio.winner === 'song2' && voteRatio.isLandslide ? 'landslide' : ''}`}>
|
||||
{voteRatio.song2Percent}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasVoted && (
|
||||
<div className="voting-actions">
|
||||
<button
|
||||
className="btn primary"
|
||||
className={`btn primary ${isOffline ? 'offline' : ''}`}
|
||||
onClick={handleSubmitVote}
|
||||
disabled={!selectedSong}
|
||||
>
|
||||
Abstimmen
|
||||
{isOffline ? 'Offline Abstimmen' : 'Abstimmen'}
|
||||
</button>
|
||||
|
||||
{isOffline && (
|
||||
<div className="offline-notice">
|
||||
<FontAwesomeIcon icon={faExclamationTriangle} />
|
||||
<span>Deine Stimme wird gespeichert und gesendet, sobald die Verbindung wiederhergestellt ist.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{offlineVoteStatus === 'queued' && (
|
||||
<div className="offline-vote-status">
|
||||
<span>Stimme gespeichert! Wird gesendet, wenn online.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{offlineVoteStatus === 'error' && (
|
||||
<div className="offline-vote-status error">
|
||||
<span>Fehler beim Speichern der Stimme.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="voting-status">
|
||||
<p>{hasVoted ? 'Warte auf andere Spieler...' : 'Wähle deinen Favoriten!'}</p>
|
||||
|
||||
{isReconnecting && (
|
||||
<div className="reconnecting-notice">
|
||||
<span>Versuche die Verbindung wiederherzustellen...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="votes-count">
|
||||
<span>{battle.voteCount || 0}</span> von <span>{lobby?.players?.filter(p => p.isConnected).length || 0}</span> Stimmen
|
||||
{isOffline && <span className="offline-badge"> (Offline-Modus)</span>}
|
||||
</div>
|
||||
|
||||
{/* Liste der Spielerstimmen */}
|
||||
@@ -254,12 +421,20 @@ function VotingScreen() {
|
||||
const hasPlayerVoted = battle.votes &&
|
||||
Object.keys(battle.votes).includes(player.id);
|
||||
|
||||
// Highlight current player to show they've voted offline
|
||||
const isCurrentPlayerOfflineVoted = player.id === currentPlayer.id &&
|
||||
isOffline &&
|
||||
hasVoted;
|
||||
|
||||
return (
|
||||
<li key={player.id} className={hasPlayerVoted ? 'voted' : 'not-voted'}>
|
||||
{player.name} {player.id === currentPlayer.id && '(Du)'}
|
||||
<li key={player.id} className={`${hasPlayerVoted ? 'voted' : 'not-voted'} ${isCurrentPlayerOfflineVoted ? 'offline-voted' : ''}`}>
|
||||
{getDisplayName(player, lobby, currentPlayer)} {player.id === currentPlayer.id && lobby.settings.hidePlayerNames && '(Du)'}
|
||||
{hasPlayerVoted &&
|
||||
<FontAwesomeIcon icon={faCheck} className="vote-icon" />
|
||||
}
|
||||
{isCurrentPlayerOfflineVoted &&
|
||||
<FontAwesomeIcon icon={faExclamationTriangle} className="offline-icon" />
|
||||
}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
@@ -11,35 +11,61 @@ const GameContext = createContext(null);
|
||||
export function SocketProvider({ children }) {
|
||||
const [socket, setSocket] = useState(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isReconnecting, setIsReconnecting] = useState(false);
|
||||
const [reconnectionAttempts, setReconnectionAttempts] = useState(0);
|
||||
const [offlineActions, setOfflineActions] = useState([]);
|
||||
const [lastActivityTime, setLastActivityTime] = useState(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
// Create socket connection on component mount
|
||||
// Create socket connection on component mount with improved reconnection settings
|
||||
const socketInstance = io(import.meta.env.DEV ? 'http://localhost:5237' : window.location.origin, {
|
||||
autoConnect: true,
|
||||
reconnection: true,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 5000,
|
||||
reconnectionAttempts: 5
|
||||
reconnectionDelayMax: 10000,
|
||||
reconnectionAttempts: Infinity, // Keep trying to reconnect indefinitely
|
||||
timeout: 20000, // Longer timeout for initial connection
|
||||
randomizationFactor: 0.5 // Add some randomization to reconnection attempts to prevent thundering herd
|
||||
});
|
||||
|
||||
// Socket event listeners
|
||||
socketInstance.on('connect', () => {
|
||||
setIsConnected(true);
|
||||
setIsReconnecting(false);
|
||||
setReconnectionAttempts(0);
|
||||
console.log('Connected to server');
|
||||
|
||||
// Process any actions that were queued while offline
|
||||
if (offlineActions.length > 0) {
|
||||
console.log(`Processing ${offlineActions.length} queued actions from offline mode`);
|
||||
// Wait a moment to ensure connection is stable
|
||||
setTimeout(() => {
|
||||
processOfflineActions(socketInstance);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Try to reconnect to game if we have saved data
|
||||
const savedGameData = localStorage.getItem('songBattleGame');
|
||||
if (savedGameData) {
|
||||
try {
|
||||
const { lobbyId, playerName } = JSON.parse(savedGameData);
|
||||
const { lobbyId, playerName, lastKnownState } = JSON.parse(savedGameData);
|
||||
if (lobbyId && playerName) {
|
||||
console.log(`Attempting to reconnect to lobby: ${lobbyId}`);
|
||||
socketInstance.emit('reconnect_to_lobby', { lobbyId, playerName }, (response) => {
|
||||
socketInstance.emit('reconnect_to_lobby', {
|
||||
lobbyId,
|
||||
playerName,
|
||||
lastKnownState // Send last known game state for server reconciliation
|
||||
}, (response) => {
|
||||
if (response.error) {
|
||||
console.error('Reconnection failed:', response.error);
|
||||
localStorage.removeItem('songBattleGame');
|
||||
// Don't remove data immediately, we might try again
|
||||
if (response.error === 'Lobby not found') {
|
||||
localStorage.removeItem('songBattleGame');
|
||||
}
|
||||
} else {
|
||||
console.log('Successfully reconnected to lobby');
|
||||
// Update the saved game data with latest state
|
||||
updateSavedGameData(response.lobby);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -50,25 +76,200 @@ export function SocketProvider({ children }) {
|
||||
}
|
||||
});
|
||||
|
||||
socketInstance.on('disconnect', () => {
|
||||
socketInstance.on('disconnect', (reason) => {
|
||||
setIsConnected(false);
|
||||
console.log('Disconnected from server');
|
||||
console.log(`Disconnected from server: ${reason}`);
|
||||
|
||||
// If the disconnection is expected, don't try to reconnect
|
||||
if (reason === 'io client disconnect') {
|
||||
console.log('Disconnection was initiated by the client');
|
||||
} else {
|
||||
// Start reconnection process for unexpected disconnections
|
||||
setIsReconnecting(true);
|
||||
}
|
||||
});
|
||||
|
||||
socketInstance.on('connect_error', (error) => {
|
||||
console.error('Connection error:', error);
|
||||
setIsReconnecting(true);
|
||||
});
|
||||
|
||||
socketInstance.on('reconnect_attempt', (attemptNumber) => {
|
||||
console.log(`Reconnection attempt #${attemptNumber}`);
|
||||
setReconnectionAttempts(attemptNumber);
|
||||
});
|
||||
|
||||
socketInstance.on('reconnect', () => {
|
||||
console.log('Reconnected to server after interruption');
|
||||
setIsReconnecting(false);
|
||||
setReconnectionAttempts(0);
|
||||
});
|
||||
|
||||
socketInstance.on('reconnect_error', (error) => {
|
||||
console.error('Reconnection error:', error);
|
||||
});
|
||||
|
||||
socketInstance.on('reconnect_failed', () => {
|
||||
console.error('Failed to reconnect to server after maximum attempts');
|
||||
setIsReconnecting(false);
|
||||
});
|
||||
|
||||
// Setup a heartbeat to detect silent disconnections
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
if (socketInstance && isConnected) {
|
||||
// Check if we've seen activity recently (within 15 seconds)
|
||||
const timeSinceLastActivity = Date.now() - lastActivityTime;
|
||||
if (timeSinceLastActivity > 15000) {
|
||||
console.log('No recent activity, sending ping to verify connection');
|
||||
socketInstance.emit('ping', () => {
|
||||
// Update activity time when we get a response
|
||||
setLastActivityTime(Date.now());
|
||||
});
|
||||
|
||||
// Set a timeout to check if the ping was successful
|
||||
setTimeout(() => {
|
||||
const newTimeSinceLastActivity = Date.now() - lastActivityTime;
|
||||
if (newTimeSinceLastActivity > 15000) {
|
||||
console.warn('No response to ping, connection may be dead');
|
||||
// Force a reconnection attempt
|
||||
socketInstance.disconnect().connect();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
}, 30000); // Check every 30 seconds
|
||||
|
||||
setSocket(socketInstance);
|
||||
|
||||
// Clean up socket connection on unmount
|
||||
// Function to process queued offline actions
|
||||
const processOfflineActions = (socket) => {
|
||||
if (!socket || !offlineActions.length) return;
|
||||
|
||||
// Process each action in sequence
|
||||
const processAction = (index) => {
|
||||
if (index >= offlineActions.length) {
|
||||
// All actions processed, clear the queue
|
||||
setOfflineActions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const { action, payload, callback } = offlineActions[index];
|
||||
console.log(`Processing offline action: ${action}`, payload);
|
||||
|
||||
// Emit the action to the server
|
||||
socket.emit(action, payload, (response) => {
|
||||
if (response.error) {
|
||||
console.error(`Error processing offline action ${action}:`, response.error);
|
||||
} else {
|
||||
console.log(`Successfully processed offline action: ${action}`);
|
||||
if (callback) callback(response);
|
||||
}
|
||||
|
||||
// Process the next action
|
||||
processAction(index + 1);
|
||||
});
|
||||
};
|
||||
|
||||
// Start processing from the first action
|
||||
processAction(0);
|
||||
};
|
||||
|
||||
// Update activity timestamp on any received message
|
||||
const originalOnEvent = socketInstance.onEvent;
|
||||
socketInstance.onEvent = (packet) => {
|
||||
setLastActivityTime(Date.now());
|
||||
originalOnEvent.call(socketInstance, packet);
|
||||
};
|
||||
|
||||
// Function to update saved game data with latest state
|
||||
const updateSavedGameData = (lobby) => {
|
||||
if (!lobby) return;
|
||||
|
||||
const currentData = localStorage.getItem('songBattleGame');
|
||||
if (currentData) {
|
||||
try {
|
||||
const data = JSON.parse(currentData);
|
||||
data.lastKnownState = {
|
||||
gameState: lobby.state,
|
||||
currentBattle: lobby.currentBattle,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
localStorage.setItem('songBattleGame', JSON.stringify(data));
|
||||
} catch (e) {
|
||||
console.error('Error updating saved game data:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Clean up on unmount
|
||||
return () => {
|
||||
clearInterval(heartbeatInterval);
|
||||
socketInstance.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Offline mode detection
|
||||
const [isOffline, setIsOffline] = useState(!navigator.onLine);
|
||||
|
||||
// Monitor browser's online/offline status
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOffline(false);
|
||||
const handleOffline = () => setIsOffline(true);
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Add an action to the offline queue
|
||||
const queueOfflineAction = (action, payload, callback) => {
|
||||
console.log(`Queuing offline action: ${action}`, payload);
|
||||
setOfflineActions(prev => [...prev, { action, payload, callback }]);
|
||||
|
||||
// Store in localStorage as fallback
|
||||
try {
|
||||
const offlineQueue = JSON.parse(localStorage.getItem('offlineActionQueue') || '[]');
|
||||
offlineQueue.push({ action, payload, timestamp: Date.now() });
|
||||
localStorage.setItem('offlineActionQueue', JSON.stringify(offlineQueue));
|
||||
} catch (e) {
|
||||
console.error('Error storing offline action in localStorage:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// Emit an event, queue it if offline
|
||||
const safeEmit = (eventName, data, callback) => {
|
||||
// Update activity timestamp
|
||||
setLastActivityTime(Date.now());
|
||||
|
||||
if (socket && isConnected && !isOffline) {
|
||||
// Online - send immediately
|
||||
socket.emit(eventName, data, callback);
|
||||
} else {
|
||||
// Offline or disconnected - queue for later
|
||||
queueOfflineAction(eventName, data, callback);
|
||||
|
||||
// For specific actions, provide optimistic UI updates
|
||||
if (eventName === 'submit_vote' && data.songId) {
|
||||
// Optimistically show the vote locally
|
||||
// Implementation depends on your UI update mechanism
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SocketContext.Provider value={{ socket, isConnected }}>
|
||||
<SocketContext.Provider value={{
|
||||
socket,
|
||||
isConnected,
|
||||
isReconnecting,
|
||||
reconnectionAttempts,
|
||||
isOffline,
|
||||
safeEmit,
|
||||
queueOfflineAction
|
||||
}}>
|
||||
{children}
|
||||
</SocketContext.Provider>
|
||||
);
|
||||
@@ -78,7 +279,7 @@ export function SocketProvider({ children }) {
|
||||
* Game state provider - manages game state and provides methods for game actions
|
||||
*/
|
||||
export function GameProvider({ children }) {
|
||||
const { socket, isConnected } = useContext(SocketContext);
|
||||
const { socket, isConnected, isOffline, isReconnecting, safeEmit } = useContext(SocketContext);
|
||||
const [lobby, setLobby] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [currentPlayer, setCurrentPlayer] = useState(null);
|
||||
@@ -86,12 +287,38 @@ export function GameProvider({ children }) {
|
||||
// Save game info when lobby is joined
|
||||
useEffect(() => {
|
||||
if (lobby && currentPlayer) {
|
||||
sessionStorage.setItem('songBattleGame', JSON.stringify({
|
||||
const savedData = {
|
||||
lobbyId: lobby.id,
|
||||
playerName: currentPlayer.name
|
||||
}));
|
||||
playerName: currentPlayer.name,
|
||||
lastKnownState: {
|
||||
gameState: lobby.state,
|
||||
currentBattle: lobby.currentBattle,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
};
|
||||
localStorage.setItem('songBattleGame', JSON.stringify(savedData));
|
||||
}
|
||||
}, [lobby, currentPlayer]);
|
||||
|
||||
// Helper function to update saved game data
|
||||
const updateSavedGameData = (updatedLobby) => {
|
||||
if (!updatedLobby || !currentPlayer) return;
|
||||
|
||||
const savedData = {
|
||||
lobbyId: updatedLobby.id,
|
||||
playerName: currentPlayer.name,
|
||||
lastKnownState: {
|
||||
gameState: updatedLobby.state,
|
||||
currentBattle: updatedLobby.currentBattle,
|
||||
playerState: {
|
||||
isReady: updatedLobby.players?.find(p => p.id === currentPlayer.id)?.isReady,
|
||||
hasVoted: updatedLobby.currentBattle?.votes?.[currentPlayer.id] !== undefined
|
||||
},
|
||||
timestamp: Date.now()
|
||||
}
|
||||
};
|
||||
localStorage.setItem('songBattleGame', JSON.stringify(savedData));
|
||||
};
|
||||
|
||||
// Clear error after 5 seconds
|
||||
useEffect(() => {
|
||||
@@ -212,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
|
||||
const handleGameFinished = (data) => {
|
||||
setLobby(prevLobby => {
|
||||
@@ -234,11 +474,13 @@ export function GameProvider({ children }) {
|
||||
socket.on('songs_updated', handleSongsUpdated);
|
||||
socket.on('player_status_changed', handlePlayerStatusChanged);
|
||||
socket.on('vote_submitted', handleVoteSubmitted);
|
||||
socket.on('battle_ended', handleBattleEnded);
|
||||
socket.on('tournament_started', data => {
|
||||
console.log('Tournament started event received:', data);
|
||||
setLobby(prevLobby => prevLobby ? {...prevLobby, state: data.state} : prevLobby);
|
||||
});
|
||||
socket.on('new_battle', handleNewBattle);
|
||||
socket.on('battle_ended', handleBattleEnded);
|
||||
socket.on('game_finished', handleGameFinished);
|
||||
|
||||
// Clean up listeners on unmount
|
||||
@@ -252,6 +494,7 @@ export function GameProvider({ children }) {
|
||||
socket.off('player_status_changed', handlePlayerStatusChanged);
|
||||
socket.off('vote_submitted', handleVoteSubmitted);
|
||||
socket.off('new_battle', handleNewBattle);
|
||||
socket.off('battle_ended', handleBattleEnded);
|
||||
socket.off('game_finished', handleGameFinished);
|
||||
};
|
||||
}, [socket]);
|
||||
@@ -263,7 +506,7 @@ export function GameProvider({ children }) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('create_lobby', { playerName }, (response) => {
|
||||
safeEmit('create_lobby', { playerName }, (response) => {
|
||||
if (response.error) {
|
||||
setError(response.error);
|
||||
} else {
|
||||
@@ -284,7 +527,7 @@ export function GameProvider({ children }) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('join_lobby', { lobbyId, playerName }, (response) => {
|
||||
safeEmit('join_lobby', { lobbyId, playerName }, (response) => {
|
||||
if (response.error) {
|
||||
setError(response.error);
|
||||
} else {
|
||||
@@ -305,7 +548,7 @@ export function GameProvider({ children }) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('update_settings', { settings }, (response) => {
|
||||
safeEmit('update_settings', { settings }, (response) => {
|
||||
if (response.error) {
|
||||
setError(response.error);
|
||||
}
|
||||
@@ -319,7 +562,7 @@ export function GameProvider({ children }) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('start_game', {}, (response) => {
|
||||
safeEmit('start_game', {}, (response) => {
|
||||
if (response.error) {
|
||||
setError(response.error);
|
||||
}
|
||||
@@ -338,7 +581,7 @@ export function GameProvider({ children }) {
|
||||
console.log('Current lobby state before adding song:', lobby);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
socket.emit('add_song', { song }, (response) => {
|
||||
safeEmit('add_song', { song }, (response) => {
|
||||
console.log('Song addition response:', response);
|
||||
if (response.error) {
|
||||
console.error('Error adding song:', response.error);
|
||||
@@ -368,6 +611,9 @@ export function GameProvider({ children }) {
|
||||
console.log('Updated lobby that was set:', updatedLobby);
|
||||
}, 0);
|
||||
|
||||
// Store the latest state for offline reconciliation
|
||||
updateSavedGameData(updatedLobby);
|
||||
|
||||
// Resolve with the updated lobby
|
||||
resolve(updatedLobby);
|
||||
} else {
|
||||
@@ -378,8 +624,7 @@ export function GameProvider({ children }) {
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Search for songs on YouTube
|
||||
// Search for songs on YouTube
|
||||
const searchYouTube = (query) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!socket || !isConnected) {
|
||||
@@ -388,7 +633,7 @@ export function GameProvider({ children }) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('search_youtube', { query }, (response) => {
|
||||
safeEmit('search_youtube', { query }, (response) => {
|
||||
if (response.error) {
|
||||
setError(response.error);
|
||||
reject(response.error);
|
||||
@@ -398,7 +643,7 @@ export function GameProvider({ children }) {
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// Get metadata for a YouTube video by ID
|
||||
const getYouTubeMetadata = (videoId) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -408,7 +653,7 @@ export function GameProvider({ children }) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('get_video_metadata', { videoId }, (response) => {
|
||||
safeEmit('get_video_metadata', { videoId }, (response) => {
|
||||
if (response.error) {
|
||||
setError(response.error);
|
||||
reject(response.error);
|
||||
@@ -426,9 +671,12 @@ export function GameProvider({ children }) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('remove_song', { songId }, (response) => {
|
||||
safeEmit('remove_song', { songId }, (response) => {
|
||||
if (response.error) {
|
||||
setError(response.error);
|
||||
} else if (response.lobby) {
|
||||
setLobby(response.lobby);
|
||||
updateSavedGameData(response.lobby);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -440,9 +688,12 @@ export function GameProvider({ children }) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('player_ready', {}, (response) => {
|
||||
safeEmit('player_ready', {}, (response) => {
|
||||
if (response.error) {
|
||||
setError(response.error);
|
||||
} else if (response.lobby) {
|
||||
setLobby(response.lobby);
|
||||
updateSavedGameData(response.lobby);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -454,9 +705,12 @@ export function GameProvider({ children }) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('submit_vote', { songId }, (response) => {
|
||||
safeEmit('submit_vote', { songId }, (response) => {
|
||||
if (response.error) {
|
||||
setError(response.error);
|
||||
} else if (response.lobby) {
|
||||
setLobby(response.lobby);
|
||||
updateSavedGameData(response.lobby);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -468,12 +722,31 @@ export function GameProvider({ children }) {
|
||||
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 (
|
||||
<GameContext.Provider value={{
|
||||
lobby,
|
||||
error,
|
||||
currentPlayer,
|
||||
isHost: currentPlayer && lobby && currentPlayer.id === lobby.hostId,
|
||||
isOffline: isOffline,
|
||||
isReconnecting,
|
||||
createLobby,
|
||||
joinLobby,
|
||||
updateSettings,
|
||||
@@ -484,7 +757,8 @@ export function GameProvider({ children }) {
|
||||
submitVote,
|
||||
leaveLobby,
|
||||
searchYouTube,
|
||||
getYouTubeMetadata
|
||||
getYouTubeMetadata,
|
||||
proceedToNextBattle
|
||||
}}>
|
||||
{children}
|
||||
</GameContext.Provider>
|
||||
|
45
client/src/utils/playerUtils.js
Normal file
45
client/src/utils/playerUtils.js
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Utility functions for player name handling
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns an anonymous name for a player when hidePlayerNames is enabled
|
||||
* @param {string} playerId - The player's ID
|
||||
* @param {number} index - The player's index in a list (optional)
|
||||
* @param {boolean} isCurrentPlayer - Whether this is the current player
|
||||
* @returns {string} - Anonymous name or indicator
|
||||
*/
|
||||
export const getAnonymousName = (playerId, index = null, isCurrentPlayer = false) => {
|
||||
if (isCurrentPlayer) {
|
||||
return 'Du';
|
||||
}
|
||||
|
||||
// Use index-based naming if provided
|
||||
if (index !== null) {
|
||||
return `Spieler ${index + 1}`;
|
||||
}
|
||||
|
||||
// Use last 4 characters of ID as fallback
|
||||
const shortId = playerId.slice(-4);
|
||||
return `Spieler ${shortId}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the appropriate player name based on game settings
|
||||
* @param {Object} player - The player object
|
||||
* @param {Object} lobby - The lobby object
|
||||
* @param {Object} currentPlayer - The current player
|
||||
* @param {number} index - The player's index in a list (optional)
|
||||
* @returns {string} - The player name or anonymous identifier
|
||||
*/
|
||||
export const getDisplayName = (player, lobby, currentPlayer, index = null) => {
|
||||
const isCurrentPlayer = player.id === currentPlayer?.id;
|
||||
|
||||
// If hidePlayerNames is enabled
|
||||
if (lobby?.settings?.hidePlayerNames) {
|
||||
return getAnonymousName(player.id, index, isCurrentPlayer);
|
||||
}
|
||||
|
||||
// Otherwise return the actual name
|
||||
return player.name;
|
||||
};
|
430
server/game.js
430
server/game.js
@@ -28,7 +28,8 @@ class GameManager {
|
||||
settings: {
|
||||
songsPerPlayer: 3,
|
||||
maxPlayers: 10,
|
||||
minPlayers: 3
|
||||
minPlayers: 3,
|
||||
hidePlayerNames: false
|
||||
},
|
||||
players: [{
|
||||
id: hostId,
|
||||
@@ -112,9 +113,10 @@ class GameManager {
|
||||
* @param {string} playerId - New ID of the reconnecting player
|
||||
* @param {string} lobbyId - ID of the lobby
|
||||
* @param {string} playerName - Name of the player
|
||||
* @param {Object} lastKnownState - Last known game state from client
|
||||
* @returns {Object} Lobby data or error
|
||||
*/
|
||||
handleReconnect(playerId, lobbyId, playerName) {
|
||||
handleReconnect(playerId, lobbyId, playerName, lastKnownState = null) {
|
||||
// Check if lobby exists
|
||||
if (!this.lobbies.has(lobbyId)) {
|
||||
return { error: 'Lobby not found' };
|
||||
@@ -135,6 +137,65 @@ class GameManager {
|
||||
// Update map
|
||||
this.playerToLobby.set(playerId, lobbyId);
|
||||
|
||||
// Handle state reconciliation if lastKnownState is provided
|
||||
if (lastKnownState && lastKnownState.gameState && lastKnownState.timestamp) {
|
||||
console.log(`Reconciling player state based on last known state: ${lastKnownState.gameState} from ${new Date(lastKnownState.timestamp).toISOString()}`);
|
||||
|
||||
// If the client's last known state was VOTING and the current battle has votes
|
||||
if (lastKnownState.gameState === 'VOTING' &&
|
||||
lastKnownState.currentBattle &&
|
||||
lastKnownState.playerState &&
|
||||
lastKnownState.playerState.hasVoted) {
|
||||
|
||||
// The player already voted in this battle according to their local state
|
||||
// If we still have the same battle, ensure their vote is counted
|
||||
if (lobby.currentBattle &&
|
||||
lastKnownState.currentBattle.round === lobby.currentBattle.round) {
|
||||
|
||||
console.log(`Player ${playerName} had voted in battle round ${lastKnownState.currentBattle.round}, checking if vote needs restoration`);
|
||||
|
||||
// Check if their vote is missing
|
||||
const hasVote = lobby.currentBattle.votes &&
|
||||
(lobby.currentBattle.votes.has(playerId) ||
|
||||
Object.keys(lobby.currentBattle.votes).includes(playerId));
|
||||
|
||||
// If their vote is missing, we need to determine which song they voted for
|
||||
if (!hasVote && lastKnownState.currentBattle.votes) {
|
||||
// Find which song they voted for
|
||||
const votedSongId = lastKnownState.currentBattle.votes[lobby.players[playerIndex].id];
|
||||
if (votedSongId) {
|
||||
console.log(`Restoring player ${playerName}'s vote for song ${votedSongId}`);
|
||||
// Recreate their vote
|
||||
this.submitVote(playerId, votedSongId, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the client's last known state was SONG_SUBMISSION and they were ready
|
||||
if (lastKnownState.gameState === 'SONG_SUBMISSION' &&
|
||||
lastKnownState.playerState &&
|
||||
lastKnownState.playerState.isReady &&
|
||||
lobby.state === 'SONG_SUBMISSION' &&
|
||||
!lobby.players[playerIndex].isReady) {
|
||||
|
||||
console.log(`Restoring ready status for player ${playerName}`);
|
||||
lobby.players[playerIndex].isReady = true;
|
||||
|
||||
// Check if all players are now ready
|
||||
const allReady = lobby.players.every(p => p.isReady || !p.isConnected);
|
||||
if (allReady) {
|
||||
console.log('All players ready after reconnection, starting tournament...');
|
||||
this._startTournament(lobby);
|
||||
return {
|
||||
lobby,
|
||||
lobbyId,
|
||||
tournamentStarted: true
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { lobby, lobbyId };
|
||||
}
|
||||
|
||||
@@ -165,6 +226,11 @@ class GameManager {
|
||||
return { error: 'Max players must be between 3 and 20' };
|
||||
}
|
||||
|
||||
// Validate boolean settings
|
||||
if (settings.hidePlayerNames !== undefined && typeof settings.hidePlayerNames !== 'boolean') {
|
||||
settings.hidePlayerNames = Boolean(settings.hidePlayerNames);
|
||||
}
|
||||
|
||||
// Update settings
|
||||
lobby.settings = {
|
||||
...lobby.settings,
|
||||
@@ -220,7 +286,6 @@ class GameManager {
|
||||
// Check if player is in a lobby
|
||||
let lobbyId = this.playerToLobby.get(playerId);
|
||||
if (!lobbyId) {
|
||||
// If no mapping exists, try to find the player in any lobby
|
||||
console.log(`[DEBUG] No lobby mapping found for player ID: ${playerId}, trying to locate player...`);
|
||||
|
||||
// Search all lobbies for this player
|
||||
@@ -266,31 +331,6 @@ class GameManager {
|
||||
const playerIndex = lobby.players.findIndex(p => p.id === playerId);
|
||||
if (playerIndex === -1) {
|
||||
console.log(`[DEBUG] Player ID ${playerId} not found in lobby ${lobbyId}.`);
|
||||
|
||||
// First try: Find the host if this is a host request
|
||||
if (playerId === lobby.hostId || lobby.players.some(p => p.id === lobby.hostId)) {
|
||||
console.log('[DEBUG] This appears to be the host. Looking for host player...');
|
||||
const hostIndex = lobby.players.findIndex(p => p.id === lobby.hostId);
|
||||
if (hostIndex !== -1) {
|
||||
console.log(`[DEBUG] Found host at index ${hostIndex}`);
|
||||
return this._addSongToPlayer(hostIndex, lobby, lobbyId, song, playerId);
|
||||
}
|
||||
}
|
||||
|
||||
// Second try: Match any connected player as fallback
|
||||
console.log('[DEBUG] Trying to find any connected player...');
|
||||
const connectedIndex = lobby.players.findIndex(p => p.isConnected);
|
||||
if (connectedIndex !== -1) {
|
||||
console.log(`[DEBUG] Found connected player at index ${connectedIndex}`);
|
||||
return this._addSongToPlayer(connectedIndex, lobby, lobbyId, song, playerId);
|
||||
}
|
||||
|
||||
// If we still can't find a match, use the first player as last resort
|
||||
if (lobby.players.length > 0) {
|
||||
console.log('[DEBUG] Using first player as last resort');
|
||||
return this._addSongToPlayer(0, lobby, lobbyId, song, playerId);
|
||||
}
|
||||
|
||||
return { error: 'Player not found in lobby' };
|
||||
}
|
||||
|
||||
@@ -519,9 +559,10 @@ class GameManager {
|
||||
* Submit a vote for a song in a battle
|
||||
* @param {string} playerId - ID of the voting player
|
||||
* @param {string} songId - ID of the voted song
|
||||
* @param {boolean} isRestoredVote - Whether this is a restored vote during reconnection
|
||||
* @returns {Object} Updated lobby data or error
|
||||
*/
|
||||
submitVote(playerId, songId) {
|
||||
submitVote(playerId, songId, isRestoredVote = false) {
|
||||
const lobbyId = this.playerToLobby.get(playerId);
|
||||
if (!lobbyId) {
|
||||
return { error: 'Player not in a lobby' };
|
||||
@@ -539,13 +580,19 @@ class GameManager {
|
||||
return { error: 'No active battle' };
|
||||
}
|
||||
|
||||
// For bye battles (automatic advancement), only allow the host to vote/advance
|
||||
if (lobby.currentBattle.bye === true && playerId !== lobby.hostId) {
|
||||
return { error: 'Only the host can advance bye rounds' };
|
||||
}
|
||||
|
||||
// Check if player has already voted in this battle
|
||||
if (lobby.currentBattle.votes.has(playerId)) {
|
||||
if (lobby.currentBattle.votes && Object.prototype.hasOwnProperty.call(lobby.currentBattle.votes, playerId) && !isRestoredVote) {
|
||||
return { error: 'Already voted in this battle' };
|
||||
}
|
||||
|
||||
// Check if the voted song is part of the current battle
|
||||
if (songId !== lobby.currentBattle.song1.id && songId !== lobby.currentBattle.song2.id) {
|
||||
if (songId !== lobby.currentBattle.song1.id &&
|
||||
(lobby.currentBattle.song2 && songId !== lobby.currentBattle.song2.id)) {
|
||||
return { error: 'Invalid song ID' };
|
||||
}
|
||||
|
||||
@@ -553,25 +600,66 @@ class GameManager {
|
||||
const player = lobby.players.find(p => p.id === playerId);
|
||||
const playerName = player ? player.name : 'Unknown Player';
|
||||
|
||||
// Initialize votes Object if it doesn't exist
|
||||
if (!lobby.currentBattle.votes) {
|
||||
lobby.currentBattle.votes = {};
|
||||
}
|
||||
|
||||
// Record the vote with player name for UI display
|
||||
lobby.currentBattle.votes.set(playerId, {
|
||||
lobby.currentBattle.votes[playerId] = {
|
||||
songId,
|
||||
playerName
|
||||
});
|
||||
};
|
||||
|
||||
// Update vote counts
|
||||
if (songId === lobby.currentBattle.song1.id) {
|
||||
lobby.currentBattle.song1Votes++;
|
||||
} else {
|
||||
lobby.currentBattle.song2Votes++;
|
||||
lobby.currentBattle.song1Votes = (lobby.currentBattle.song1Votes || 0) + 1;
|
||||
} else if (lobby.currentBattle.song2) {
|
||||
lobby.currentBattle.song2Votes = (lobby.currentBattle.song2Votes || 0) + 1;
|
||||
}
|
||||
|
||||
// Add a voteCount attribute for easier UI rendering
|
||||
lobby.currentBattle.voteCount = lobby.currentBattle.votes.size;
|
||||
lobby.currentBattle.voteCount = Object.keys(lobby.currentBattle.votes).length;
|
||||
|
||||
// Check if all connected players have voted
|
||||
// For bye battles, the host's vote is all that's needed
|
||||
if (lobby.currentBattle.bye === true && playerId === lobby.hostId) {
|
||||
// Determine winner (in bye battles, it's always song1)
|
||||
const winnerSongId = lobby.currentBattle.song1.id;
|
||||
|
||||
lobby.currentBattle.winner = winnerSongId;
|
||||
|
||||
// Save battle to history
|
||||
lobby.battles.push({
|
||||
round: lobby.currentBattle.round,
|
||||
song1: lobby.currentBattle.song1,
|
||||
song2: lobby.currentBattle.song2,
|
||||
song1Votes: lobby.currentBattle.song1Votes,
|
||||
song2Votes: 0,
|
||||
winner: winnerSongId,
|
||||
bye: true
|
||||
});
|
||||
|
||||
// Store current battle for potential future reference
|
||||
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 };
|
||||
}
|
||||
|
||||
// For regular battles, check if all connected players have voted
|
||||
const connectedPlayers = lobby.players.filter(p => p.isConnected).length;
|
||||
const voteCount = lobby.currentBattle.votes.size;
|
||||
const voteCount = Object.keys(lobby.currentBattle.votes).length;
|
||||
|
||||
if (voteCount >= connectedPlayers) {
|
||||
// Determine winner
|
||||
@@ -591,8 +679,132 @@ class GameManager {
|
||||
winner: winnerSongId
|
||||
});
|
||||
|
||||
// Move to next battle or finish tournament
|
||||
this._moveToNextBattle(lobby);
|
||||
// Store current battle as previousBattle and set state to BATTLE_RESULT
|
||||
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 };
|
||||
@@ -646,7 +858,7 @@ class GameManager {
|
||||
// For voting state, check if we need to progress
|
||||
if (lobby.state === 'VOTING' && lobby.currentBattle) {
|
||||
// Add null check for votes property
|
||||
const totalVotes = lobby.currentBattle.votes?.size || 0;
|
||||
const totalVotes = lobby.currentBattle.votes ? Object.keys(lobby.currentBattle.votes).length : 0;
|
||||
const remainingPlayers = lobby.players.filter(p => p.isConnected).length;
|
||||
|
||||
if (totalVotes >= remainingPlayers && totalVotes > 0) {
|
||||
@@ -728,9 +940,10 @@ class GameManager {
|
||||
song1: byeSong,
|
||||
song2: null, // Bye
|
||||
bye: true,
|
||||
winner: byeSong.id,
|
||||
winner: null, // Set to null until host advances it
|
||||
song1Votes: 0,
|
||||
song2Votes: 0
|
||||
song2Votes: 0,
|
||||
votes: {} // Initialize votes as an empty object
|
||||
});
|
||||
}
|
||||
|
||||
@@ -744,7 +957,7 @@ class GameManager {
|
||||
song1Votes: 0,
|
||||
song2Votes: 0,
|
||||
winner: null,
|
||||
votes: new Map()
|
||||
votes: {}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -760,8 +973,8 @@ class GameManager {
|
||||
// Ensure we create a proper battle object with all required fields
|
||||
lobby.currentBattle = {
|
||||
...brackets[0],
|
||||
// Make sure votes is a Map (it might be serialized incorrectly)
|
||||
votes: brackets[0].votes || new Map(),
|
||||
// Make sure votes is an object (not a Map)
|
||||
votes: brackets[0].votes || {},
|
||||
voteCount: 0
|
||||
};
|
||||
|
||||
@@ -778,18 +991,81 @@ class GameManager {
|
||||
/**
|
||||
* Move to next battle or finish tournament
|
||||
* @param {Object} lobby - Lobby object
|
||||
* @returns {boolean} true if tournament has finished, false otherwise
|
||||
* @private
|
||||
*/
|
||||
_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
|
||||
lobby.currentBracketIndex++;
|
||||
|
||||
if (lobby.currentBracketIndex < lobby.brackets.length) {
|
||||
// Move to next battle
|
||||
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
|
||||
const winners = lobby.brackets
|
||||
.filter(b => !b.bye) // Skip byes
|
||||
@@ -808,9 +1084,10 @@ class GameManager {
|
||||
// If only one song remains, we have a winner
|
||||
if (nextRoundSongs.length <= 1) {
|
||||
lobby.state = 'FINISHED';
|
||||
lobby.finalWinner = nextRoundSongs[0];
|
||||
lobby.finalWinner = nextRoundSongs.length > 0 ? nextRoundSongs[0] : 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
|
||||
@@ -825,9 +1102,10 @@ class GameManager {
|
||||
song1: byeSong,
|
||||
song2: null,
|
||||
bye: true,
|
||||
winner: byeSong.id,
|
||||
winner: null, // Set to null until host advances it
|
||||
song1Votes: 0,
|
||||
song2Votes: 0
|
||||
song2Votes: 0,
|
||||
votes: {} // Initialize votes as an object
|
||||
});
|
||||
}
|
||||
|
||||
@@ -841,7 +1119,7 @@ class GameManager {
|
||||
song1Votes: 0,
|
||||
song2Votes: 0,
|
||||
winner: null,
|
||||
votes: new Map()
|
||||
votes: {}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -853,19 +1131,41 @@ class GameManager {
|
||||
// Set first battle of new round
|
||||
if (nextRound.length > 0) {
|
||||
lobby.currentBattle = nextRound[0];
|
||||
|
||||
// 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 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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]];
|
||||
}
|
||||
|
||||
return false; // Tournament continues
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -73,9 +73,9 @@ io.on('connection', (socket) => {
|
||||
});
|
||||
|
||||
// Attempt to reconnect to a lobby
|
||||
socket.on('reconnect_to_lobby', ({ lobbyId, playerName }, callback) => {
|
||||
socket.on('reconnect_to_lobby', ({ lobbyId, playerName, lastKnownState }, callback) => {
|
||||
try {
|
||||
const result = gameManager.handleReconnect(socket.id, lobbyId, playerName);
|
||||
const result = gameManager.handleReconnect(socket.id, lobbyId, playerName, lastKnownState);
|
||||
|
||||
if (result.error) {
|
||||
if (callback) callback(result);
|
||||
@@ -302,42 +302,73 @@ io.on('connection', (socket) => {
|
||||
// Submit a vote in a battle
|
||||
socket.on('submit_vote', ({ songId }, callback) => {
|
||||
try {
|
||||
console.log(`Player ${socket.id} voting for song ${songId}`);
|
||||
const result = gameManager.submitVote(socket.id, songId);
|
||||
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
// Notify all players about vote count
|
||||
io.to(result.lobbyId).emit('vote_submitted', {
|
||||
lobby: result.lobby
|
||||
});
|
||||
|
||||
// If battle is finished, notify about new battle
|
||||
if (result.lobby.currentBattle) {
|
||||
io.to(result.lobbyId).emit('new_battle', {
|
||||
battle: result.lobby.currentBattle
|
||||
});
|
||||
}
|
||||
|
||||
// If game is finished, notify about the winner
|
||||
if (result.lobby.state === 'FINISHED') {
|
||||
io.to(result.lobbyId).emit('game_finished', {
|
||||
winner: result.lobby.finalWinner,
|
||||
battles: result.lobby.battles
|
||||
});
|
||||
}
|
||||
|
||||
// Send response to client
|
||||
if (callback) callback(result);
|
||||
} catch (error) {
|
||||
console.error('Error submitting vote:', error);
|
||||
if (callback) callback({ error: 'Failed to submit vote' });
|
||||
if (callback) callback({ error: 'Server error while submitting vote' });
|
||||
}
|
||||
});
|
||||
|
||||
// Handle disconnection
|
||||
// Proceed to next battle after the battle result screen
|
||||
socket.on('proceed_to_next_battle', (data, callback) => {
|
||||
try {
|
||||
const result = gameManager.proceedToNextBattle(socket.id);
|
||||
|
||||
if (result.error) {
|
||||
if (callback) callback({ error: result.error });
|
||||
} else {
|
||||
// Check if the game has finished
|
||||
if (result.lobby.state === 'FINISHED') {
|
||||
console.log('Game finished, broadcasting final winner');
|
||||
io.to(result.lobbyId).emit('game_finished', {
|
||||
winner: result.lobby.finalWinner,
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
if (callback) callback(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error proceeding to next battle:', error);
|
||||
if (callback) callback({ error: 'Server error while proceeding to next battle' });
|
||||
}
|
||||
});
|
||||
|
||||
// Handle player disconnection
|
||||
socket.on('disconnect', () => {
|
||||
try {
|
||||
const result = gameManager.handleDisconnect(socket.id);
|
||||
@@ -355,6 +386,13 @@ io.on('connection', (socket) => {
|
||||
console.error('Error handling disconnect:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Simple ping handler to help with connection testing
|
||||
socket.on('ping', (callback) => {
|
||||
if (callback && typeof callback === 'function') {
|
||||
callback({ success: true, timestamp: Date.now() });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.on('error', (error) => {
|
||||
|
Reference in New Issue
Block a user