Skip to content

Commit

Permalink
Implement separate timer for word choice
Browse files Browse the repository at this point in the history
Fixes #365

* Word amount in frontend now dynamic
* there's now two timers, 30 seconds for word choice + drawingTime
  * after 30 seconds a word is chosen automatically
  • Loading branch information
Bios-Marcel committed Dec 7, 2024
1 parent b57f49b commit 04c370d
Show file tree
Hide file tree
Showing 9 changed files with 541 additions and 242 deletions.
2 changes: 1 addition & 1 deletion internal/frontend/resources/lobby.css
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ kbd {
text-align: center;
}

.word-button-container {
#word-button-container {
display: flex;
flex-direction: row;
margin-left: 20px;
Expand Down
47 changes: 28 additions & 19 deletions internal/frontend/templates/lobby.html
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,12 @@
<div id="word-dialog" class="center-dialog">
<span class="dialog-title">{{.Translation.Get "choose-a-word"}}</span>
<div class="center-dialog-content">
<div class="word-button-container">
<button id="word-button-zero" class="dialog-button"
onclick="chooseWord(0)"></button>
<button id="word-button-one" class="dialog-button" onclick="chooseWord(1)"></button>
<button id="word-button-two" class="dialog-button" onclick="chooseWord(2)"></button>
<div style="display: flex; flex-direction: column; gap: 0.5rem; align-items: center">
<div>
<span>{{.Translation.Get "word-choice-warning"}}:</span>
<span id="word-preselected" style="font-weight: bold;"></span>
</div>
<div id="word-button-container"> </div>
</div>
</div>
</div>
Expand Down Expand Up @@ -544,9 +545,8 @@
const gameOverScoreboard = document.getElementById("game-over-scoreboard");
const restartButton = document.getElementById("restart-button");
const wordDialog = document.getElementById("word-dialog");
const wordButtonZero = document.getElementById("word-button-zero");
const wordButtonOne = document.getElementById("word-button-one");
const wordButtonTwo = document.getElementById("word-button-two");
const wordPreSelected = document.getElementById("word-preselected")
const wordButtonContainer = document.getElementById("word-button-container");

const kickDialog = document.getElementById("kick-dialog");
const kickDialogPlayers = document.getElementById("kick-dialog-players");
Expand Down Expand Up @@ -1222,7 +1222,7 @@
} else if (parsed.type === "close-guess") {
appendMessage("close-guess-message", null, '{{.Translation.Get "close-guess"}}'.format(parsed.data));
} else if (parsed.type === "update-wordhint") {
// this event is (also) sent if the drawer has choosen a word, so we can hide the waitChooseDialog
wordDialog.style.visibility = "hidden";
waitChooseDialog.style.visibility = "hidden";
applyWordHints(parsed.data);
} else if (parsed.type === "message") {
Expand Down Expand Up @@ -1251,6 +1251,11 @@
}
} else if (parsed.type === "clear-drawing-board") {
clear(context);
} else if (parsed.type === "word-chosen") {
wordDialog.style.visibility = "hidden";
waitChooseDialog.style.visibility = "hidden";
setRoundTimeLeft(parsed.data.timeLeft);
applyWordHints(parsed.data.hints);
} else if (parsed.type === "next-turn") {
if (gameState === "ongoing") {
//The previous turn has ended.
Expand All @@ -1260,8 +1265,6 @@
gameState = "ongoing";
}

setRoundEndTime(parsed.data.roundEndTime);

//As soon as a turn starts, the round should be ongoing, so we make
//sure that all types of dialogs, that indicate the game isn't
//ongoing, are not visible anymore.
Expand All @@ -1277,6 +1280,7 @@

round = parsed.data.round;
updateRoundsDisplay();
setRoundTimeLeft(parsed.data.choiceTimeLeft);
applyPlayers(parsed.data.players);

set_dummy_word_hints();
Expand All @@ -1295,7 +1299,7 @@
//This dialog could potentially stay visible from last
//turn, in case nobody has chosen a word.
waitChooseDialog.style.visibility = "hidden";
promptWords(parsed.data[0], parsed.data[1], parsed.data[2]);
promptWords(parsed.data);
} else if (parsed.type === "drawing") {
applyDrawData(parsed.data);
} else if (parsed.type === "kick-vote") {
Expand Down Expand Up @@ -1370,15 +1374,15 @@
//
//FIXME The only leftover issue is that ping isn't taken into
//account, however, that's no biggie for now.
function setRoundEndTime(timeLeftMs) {
function setRoundTimeLeft(timeLeftMs) {
roundEndTime = Date.now() + timeLeftMs;
}

function handleReadyEvent(ready) {
ownerID = ready.ownerId;
ownID = ready.playerId;

setRoundEndTime(ready.roundEndTime);
setRoundTimeLeft(ready.timeLeft);
setUsernameLocally(ready.playerName);
setAllowDrawing(ready.allowDrawing);
round = ready.round;
Expand Down Expand Up @@ -1491,10 +1495,15 @@
}
}

function promptWords(wordOne, wordTwo, wordThree) {
wordButtonZero.textContent = wordOne;
wordButtonOne.textContent = wordTwo;
wordButtonTwo.textContent = wordThree;
function promptWords(data) {
wordPreSelected.textContent = data.words[data.preSelectedWord];
wordButtonContainer.replaceChildren(...data.words.map((word, index) => {
const button = createDialogButton(word);
button.onclick = () => {
chooseWord(index);
};
return button;
}));
wordDialog.style.visibility = "visible";
}

Expand All @@ -1512,7 +1521,7 @@
const secondsLeft = Math.max(0, Math.floor(msLeft / 1000));
timeLeftValue.innerText = "" + secondsLeft
} else {
timeLeftValue.innerText = "" + drawingTimeSetting;
timeLeftValue.innerText = "∞";
}
}, 500);

Expand Down
4 changes: 3 additions & 1 deletion internal/game/data.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ type Lobby struct {
hintCount int
// Round is the round that the Lobby is currently in. This is a number
// between 0 and Rounds. 0 indicates that it hasn't started yet.
Round int
Round int
wordChoiceEndTime int64
preSelectedWord int
// wordChoice represents the current choice of words present to the drawer.
wordChoice []string
Wordpack string
Expand Down
106 changes: 75 additions & 31 deletions internal/game/lobby.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,23 +158,10 @@ func (lobby *Lobby) HandleEvent(eventType string, payload []byte, player *Player
if err := easyjson.Unmarshal(payload, &wordChoice); err != nil {
return fmt.Errorf("error decoding data: %w", err)
}
chosenIndex := wordChoice.Data

if len(lobby.wordChoice) == 0 {
return errors.New("word was chosen, even though no choice was available")
}

if chosenIndex < 0 || chosenIndex >= len(lobby.wordChoice) {
return fmt.Errorf("word choice was %d, but should've been >= 0 and < %d", chosenIndex, len(lobby.wordChoice))
}

if player.State == Drawing {
lobby.selectWord(chosenIndex)

wordHintData := &Event{Type: EventTypeUpdateWordHint, Data: lobby.wordHints}
lobby.broadcastConditional(wordHintData, IsAllowedToSeeHints)
wordHintDataRevealed := &Event{Type: EventTypeUpdateWordHint, Data: lobby.wordHintsShown}
lobby.broadcastConditional(wordHintDataRevealed, IsAllowedToSeeRevealedHints)
if err := lobby.selectWord(wordChoice.Data); err != nil {
return err
}
}
} else if eventType == EventTypeKickVote {
var kickEvent StringDataEvent
Expand Down Expand Up @@ -677,23 +664,33 @@ func advanceLobbyPredefineDrawer(lobby *Lobby, roundOver bool, newDrawer *Player
newDrawer.State = Drawing
lobby.State = Ongoing
lobby.wordChoice = GetRandomWords(3, lobby)
lobby.preSelectedWord = rand.IntN(len(lobby.wordChoice))

// We use milliseconds for higher accuracy
lobby.roundEndTime = getTimeAsMillis() + int64(lobby.DrawingTime)*1000
lobby.timeLeftTicker = time.NewTicker(1 * time.Second)
go startTurnTimeTicker(lobby, lobby.timeLeftTicker)

wordChoiceDuration := 30
lobby.Broadcast(&Event{
Type: EventTypeNextTurn,
Data: &NextTurn{
Round: lobby.Round,
Players: lobby.players,
RoundEndTime: int(lobby.roundEndTime - getTimeAsMillis()),
PreviousWord: previousWord,
Round: lobby.Round,
Players: lobby.players,
ChoiceTimeLeft: wordChoiceDuration * 1000,
PreviousWord: previousWord,
},
})

lobby.WriteObject(newDrawer, &Event{Type: EventTypeYourTurn, Data: lobby.wordChoice})
lobby.wordChoiceEndTime = getTimeAsMillis() + int64(wordChoiceDuration)*1000
go func() {
timer := time.NewTimer(time.Duration(wordChoiceDuration) * time.Second)
<-timer.C

lobby.mutex.Lock()
defer lobby.mutex.Unlock()

// We let the timer run out as long as it doesn't seem to cause any
// issues and make sure it doesn't fire when it would break stuff.
lobby.selectWord(int(lobby.preSelectedWord))
}()

lobby.SendYourTurnEvent(newDrawer)
}

// advanceLobby will either start the game or jump over to the next turn.
Expand Down Expand Up @@ -847,8 +844,21 @@ func recalculateRanks(lobby *Lobby) {
}
}

func (lobby *Lobby) selectWord(wordChoiceIndex int) {
lobby.CurrentWord = lobby.wordChoice[wordChoiceIndex]
func (lobby *Lobby) selectWord(index int) error {
if lobby.State != Ongoing {
return errors.New("word was chosen, even though the game wasn't ongoing")
}

if len(lobby.wordChoice) == 0 {
return errors.New("word was chosen, even though no choice was available")
}

if index < 0 || index >= len(lobby.wordChoice) {
return fmt.Errorf("word choice was %d, but should've been >= 0 and < %d",
index, len(lobby.wordChoice))
}

lobby.CurrentWord = lobby.wordChoice[index]
lobby.wordChoice = nil

// Depending on how long the word is, a fixed amount of hints
Expand Down Expand Up @@ -896,6 +906,29 @@ func (lobby *Lobby) selectWord(wordChoiceIndex int) {
})
}
}
// We use milliseconds for higher accuracy
lobby.roundEndTime = getTimeAsMillis() + int64(lobby.DrawingTime)*1000
lobby.timeLeftTicker = time.NewTicker(1 * time.Second)
go startTurnTimeTicker(lobby, lobby.timeLeftTicker)

wordHintData := &Event{
Type: EventTypeWordChosen,
Data: &WordChosen{
Hints: lobby.wordHints,
TimeLeft: int(lobby.roundEndTime - getTimeAsMillis()),
},
}
lobby.broadcastConditional(wordHintData, IsAllowedToSeeHints)
wordHintDataRevealed := &Event{
Type: EventTypeWordChosen,
Data: &WordChosen{
Hints: lobby.wordHintsShown,
TimeLeft: int(lobby.roundEndTime - getTimeAsMillis()),
},
}
lobby.broadcastConditional(wordHintDataRevealed, IsAllowedToSeeRevealedHints)

return nil
}

// CreateLobby creates a new lobby including the initial player (owner) and
Expand Down Expand Up @@ -981,14 +1014,25 @@ func generateReadyData(lobby *Lobby, player *Player) *ReadyEvent {

if lobby.State != Ongoing {
// Clients should interpret 0 as "time over", unless the gamestate isn't "ongoing"
ready.RoundEndTime = 0
ready.TimeLeft = 0
} else {
ready.RoundEndTime = int(lobby.roundEndTime - getTimeAsMillis())
ready.TimeLeft = int(lobby.roundEndTime - getTimeAsMillis())
}

return ready
}

func (lobby *Lobby) SendYourTurnEvent(player *Player) {
lobby.WriteObject(player, &Event{
Type: EventTypeYourTurn,
Data: &YourTurn{
TimeLeft: int(lobby.wordChoiceEndTime - getTimeAsMillis()),
PreSelectedWord: lobby.preSelectedWord,
Words: lobby.wordChoice,
},
})
}

func (lobby *Lobby) OnPlayerConnectUnsynchronized(player *Player) {
player.Connected = true
recalculateRanks(lobby)
Expand All @@ -998,7 +1042,7 @@ func (lobby *Lobby) OnPlayerConnectUnsynchronized(player *Player) {
// This can happen if the player refreshes his browser page or the socket
// loses connection and reconnects quickly.
if player.State == Drawing && lobby.CurrentWord == "" {
lobby.WriteObject(player, &Event{Type: EventTypeYourTurn, Data: lobby.wordChoice})
lobby.SendYourTurnEvent(player)
}

// The player that just joined already has the most up-to-date data due
Expand Down
11 changes: 6 additions & 5 deletions internal/game/lobby_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,8 @@ func Test_wordSelectionEvent(t *testing.T) {
event, ok := message.(*Event)
if ok {
if event.Type == EventTypeYourTurn {
wordChoice = event.Data.([]string)
yourTurn := event.Data.(*YourTurn)
wordChoice = yourTurn.Words
}
}

Expand All @@ -265,12 +266,12 @@ func Test_wordSelectionEvent(t *testing.T) {
}

t.Log(e.Type)
if e.Type == "update-wordhint" {
var wordHints []*WordHint
if err := json.Unmarshal(e.Data, &wordHints); err != nil {
if e.Type == "word-chosen" {
var event WordChosen
if err := json.Unmarshal(e.Data, &event); err != nil {
t.Fatal("error unmarshalling word hints:", err)
}
wordHintEvents[player.ID] = wordHints
wordHintEvents[player.ID] = event.Hints
}
return nil
}
Expand Down
22 changes: 17 additions & 5 deletions internal/game/shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const (
const (
EventTypeUpdatePlayers = "update-players"
EventTypeUpdateWordHint = "update-wordhint"
EventTypeWordChosen = "word-chosen"
EventTypeCorrectGuess = "correct-guess"
EventTypeCloseGuess = "close-guess"
EventTypeSystemMessage = "system-message"
Expand Down Expand Up @@ -155,17 +156,28 @@ type GameOverEvent struct {
PreviousWord string `json:"previousWord"`
}

type WordChosen struct {
TimeLeft int `json:"timeLeft"`
Hints []*WordHint `json:"hints"`
}

type YourTurn struct {
TimeLeft int `json:"timeLeft"`
PreSelectedWord int `json:"preSelectedWord"`
Words []string `json:"words"`
}

// NextTurn represents the data necessary for displaying the lobby state right
// after a new turn started. Meaning that no word has been chosen yet and
// therefore there are no wordhints and no current drawing instructions.
type NextTurn struct {
// PreviousWord signals the last chosen word. If empty, no word has been
// chosen. The client can now themselves whether there has been a previous
// turn, by looking at the current gamestate.
PreviousWord string `json:"previousWord"`
Players []*Player `json:"players"`
Round int `json:"round"`
RoundEndTime int `json:"roundEndTime"`
PreviousWord string `json:"previousWord"`
Players []*Player `json:"players"`
ChoiceTimeLeft int `json:"choiceTimeLeft"`
Round int `json:"round"`
}

// OutgoingMessage represents a message in the chatroom.
Expand All @@ -191,7 +203,7 @@ type ReadyEvent struct {
OwnerID uuid.UUID `json:"ownerId"`
Round int `json:"round"`
Rounds int `json:"rounds"`
RoundEndTime int `json:"roundEndTime"`
TimeLeft int `json:"timeLeft"`
DrawingTimeSetting int `json:"drawingTimeSetting"`
AllowDrawing bool `json:"allowDrawing"`
}
Expand Down
Loading

0 comments on commit 04c370d

Please sign in to comment.