diff --git a/internal/frontend/templates/lobby.html b/internal/frontend/templates/lobby.html
index 90ec0c54..653ed17c 100644
--- a/internal/frontend/templates/lobby.html
+++ b/internal/frontend/templates/lobby.html
@@ -121,6 +121,17 @@
+
{{.Translation.Get "game-not-started-title"}}
@@ -507,6 +518,7 @@
const centerDialogs = document.getElementById("center-dialogs");
+ const spectatorChoiceDialog = document.getElementById("spectator-choice-dialog");
const unstartedDialog = document.getElementById("unstarted-dialog");
const waitChooseDialog = document.getElementById("waitchoose-dialog");
const waitChooseDrawerSpan = document.getElementById("waitchoose-drawer");
@@ -1386,8 +1398,9 @@
playerContainer.innerHTML = "";
cachedPlayers = players;
players.forEach(player => {
- //We don't wanna show the disconnected players.
- if (!player.connected) {
+ // We don't wanna show the disconnected players or spectators.
+ // the UI is cluttered enough as is.
+ if (!player.connected || player.state === "spectator") {
return;
}
diff --git a/internal/game/data.go b/internal/game/data.go
index 6fc4484a..a40cd5af 100644
--- a/internal/game/data.go
+++ b/internal/game/data.go
@@ -140,9 +140,10 @@ func (player *Player) GetUserSession() uuid.UUID {
type PlayerState string
const (
- Guessing PlayerState = "guessing"
- Drawing PlayerState = "drawing"
- Standby PlayerState = "standby"
+ Guessing PlayerState = "guessing"
+ Drawing PlayerState = "drawing"
+ Standby PlayerState = "standby"
+ Spectator PlayerState = "spectator"
)
// GetPlayer searches for a player, identifying them by usersession.
diff --git a/internal/game/lobby.go b/internal/game/lobby.go
index 0cc2b27a..b4aa048e 100644
--- a/internal/game/lobby.go
+++ b/internal/game/lobby.go
@@ -80,11 +80,19 @@ func (lobby *Lobby) HandleEvent(eventType string, payload []byte, player *Player
lobby.mutex.Lock()
defer lobby.mutex.Unlock()
+ if player.State == Spectator {
+ return nil
+ }
+
// For all followup unmarshalling of the already unmarshalled Event, we
// use mapstructure instead. It's cheaper in terms of CPU usage and
// memory usage. There are benchmarks to prove this in json_test.go.
- if eventType == EventTypeMessage {
+ if eventType == "spectate" {
+ player.desiredState = Spectator
+ } else if eventType == "participate" {
+ player.desiredState = Guessing
+ } else if eventType == EventTypeMessage {
var message StringDataEvent
if err := easyjson.Unmarshal(payload, &message); err != nil {
return fmt.Errorf("invalid data received: '%s'", string(payload))
@@ -169,7 +177,9 @@ func (lobby *Lobby) HandleEvent(eventType string, payload []byte, player *Player
wordHintData := &Event{Type: EventTypeUpdateWordHint, Data: lobby.wordHints}
lobby.broadcastConditional(wordHintData, IsGuessing)
wordHintDataRevealed := &Event{Type: EventTypeUpdateWordHint, Data: lobby.wordHintsShown}
- lobby.broadcastConditional(wordHintDataRevealed, IsNotGuessing)
+ lobby.broadcastConditional(wordHintDataRevealed, func(p *Player) bool {
+ return p.State == Drawing || p.State == Standby
+ })
}
} else if eventType == EventTypeKickVote {
if lobby.EnableVotekick {
@@ -416,6 +426,10 @@ func handleKickVoteEvent(lobby *Lobby, player *Player, toKickID uuid.UUID) {
}
playerToKick := lobby.players[playerToKickIndex]
+ // Can't kick spectators
+ if playerToKick.State == Spectator {
+ return
+ }
player.votedForKick[toKickID] = true
var voteKickCount int
@@ -602,6 +616,10 @@ func advanceLobbyPredefineDrawer(lobby *Lobby, roundOver bool, newDrawer *Player
lobby.scoreEarnedByGuessers = 0
for _, otherPlayer := range lobby.players {
+ if otherPlayer.State == Spectator {
+ continue
+ }
+
// If the round ends and people still have guessing, that means the
// "LastScore" value for the next turn has to be "no score earned".
if otherPlayer.State == Guessing {
@@ -612,6 +630,13 @@ func advanceLobbyPredefineDrawer(lobby *Lobby, roundOver bool, newDrawer *Player
otherPlayer.State = Guessing
}
+ for _, player := range lobby.players {
+ if player.desiredState != "" {
+ player.State = player.desiredState
+ player.desiredState = ""
+ }
+ }
+
recalculateRanks(lobby)
if roundOver {
@@ -635,7 +660,6 @@ func advanceLobbyPredefineDrawer(lobby *Lobby, roundOver bool, newDrawer *Player
})
}
- // Omit rest of events, since we don't need to advance.
return
}
@@ -672,29 +696,31 @@ func advanceLobby(lobby *Lobby) {
advanceLobbyPredefineDrawer(lobby, roundOver, newDrawer)
}
+func validDrawer(player *Player) bool {
+ return player.Connected || player.State == Drawing || player.State == Standby
+}
+
// determineNextDrawer returns the next person that's supposed to be drawing, but
// doesn't tell the lobby yet. The boolean signals whether the current round
// is over.
func determineNextDrawer(lobby *Lobby) (*Player, bool) {
+ // Attempt to take the next player in line.
for index, player := range lobby.players {
if player == lobby.drawer {
- // If we have someone that's drawing, take the next one
for i := index + 1; i < len(lobby.players); i++ {
- player := lobby.players[i]
- if player.Connected {
+ if player := lobby.players[i]; validDrawer(player) {
return player, false
}
}
- // No player below the current drawer has been found, therefore we
- // fallback to our default logic at the bottom.
+ // Next round
break
}
}
- // We prefer the first connected player.
+ // Start from the top of the player list again.
for _, player := range lobby.players {
- if player.Connected {
+ if validDrawer(player) {
return player, true
}
}
@@ -794,19 +820,15 @@ func recalculateRanks(lobby *Lobby) {
lastScore := math.MaxInt32
var lastRank int
for _, player := range sortedPlayers {
- if !player.Connected {
+ if !player.Connected && player.State != Spectator {
continue
}
if player.Score < lastScore {
lastRank++
- player.Rank = lastRank
lastScore = player.Score
- } else {
- // Since the players are already sorted from high to low, we only
- // have the cases higher or equal.
- player.Rank = lastRank
}
+ player.Rank = lastRank
}
}
diff --git a/internal/game/shared.go b/internal/game/shared.go
index 775409de..31cb5afc 100644
--- a/internal/game/shared.go
+++ b/internal/game/shared.go
@@ -204,6 +204,11 @@ type Player struct {
// space for new players. The player with the oldest disconnect.Time will
// get kicked.
disconnectTime *time.Time
+ // desiredState is used for state changes between spectator and player.
+ // We want to prevent people from switching in and out of the play state.
+ // While this will allow people to skip being the drawer, it will also
+ // cause them to lose points for that round.
+ desiredState PlayerState
votedForKick map[uuid.UUID]bool