Skip to content

Commit 10b5243

Browse files
authored
Implement a basic spectator mode (#333)
* Spectator mode * Mode can be switched freely in an unstarted lobby * Mode can be switched via a cancellable request during a turn. The change is applied after the turn. * Spectators view the game from the perspective of a guesser * Spectators can use the "standby" chat * There is currently no status indication (whether spectating, participating or active request) * The toolbox now hides if you are not drawing
1 parent 0672185 commit 10b5243

File tree

9 files changed

+264
-53
lines changed

9 files changed

+264
-53
lines changed

internal/frontend/resources/lobby.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,7 @@ input[type="button"]:active,
377377
flex-wrap: wrap;
378378
margin-top: 5px;
379379
grid-row: 3;
380-
grid-column: 2;
380+
grid-column: 2 / 4;
381381
height: min-content;
382382
user-select: none;
383383
}
Lines changed: 1 addition & 0 deletions
Loading

internal/frontend/templates/lobby.html

Lines changed: 139 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@
4646
<img src="{{.RootPath}}/resources/fullscreen.svg?cache_bust={{.CacheBust}}"
4747
class="header-button-image" />
4848
</button>
49+
<button onclick="toggleSpectate()" class="dialog-button header-button"
50+
alt="{{.Translation.Get "toggle-spectate"}}" title="{{.Translation.Get "toggle-spectate"}}">
51+
<img src="{{.RootPath}}/resources/spectate.svg?cache_bust={{.CacheBust}}"
52+
class="header-button-image" />
53+
</button>
4954
</div>
5055
<div id="word-container"></div>
5156
<div>
@@ -541,7 +546,7 @@
541546
function showDialog(id, title, contentNode, buttonBar) {
542547
const newDialog = document.createElement("div");
543548
newDialog.classList.add("center-dialog");
544-
if (id !== null && id !== "") {
549+
if (id && id !== "") {
545550
newDialog.id = id;
546551
}
547552

@@ -563,6 +568,37 @@
563568
centerDialogs.appendChild(newDialog);
564569
}
565570

571+
// Shows an information dialog with a button that closes the dialog and
572+
// removes it from the DOM.
573+
function showInfoDialog(title, message, buttonText) {
574+
const dialogId = "info_dialog";
575+
closeDialog(dialogId);
576+
577+
const closeButton = createDialogButton(buttonText);
578+
closeButton.addEventListener("click", event => {
579+
closeDialog(dialogId)
580+
})
581+
582+
const messageNode = document.createElement("span");
583+
messageNode.innerText = message;
584+
585+
showDialog(dialogId, title, messageNode, createDialogButtonBar(closeButton));
586+
}
587+
588+
function createDialogButton(text) {
589+
const button = document.createElement("button");
590+
button.innerText = text;
591+
button.classList.add("dialog-button");
592+
return button;
593+
}
594+
595+
function createDialogButtonBar(...buttons) {
596+
const buttonBar = document.createElement("div");
597+
buttonBar.classList.add("button-center-wrapper");
598+
buttons.forEach(button => buttonBar.appendChild(button));
599+
return buttonBar;
600+
}
601+
566602
function closeDialog(id) {
567603
const dialog = document.getElementById(id);
568604
if (dialog !== undefined && dialog !== null) {
@@ -590,16 +626,12 @@
590626
const controlsTextThree = document.createElement("p");
591627
controlsTextThree.innerHTML = '{{printf (.Translation.Get "switch-pencil-sizes") "<kbd>1</kbd>" "<kbd>4</kbd>"}}';
592628

593-
const closeButton = document.createElement("button");
594-
closeButton.innerText = '{{.Translation.Get "close"}}';
595-
closeButton.classList.add("dialog-button");
629+
const closeButton = createDialogButton('{{.Translation.Get "close"}}');
596630
closeButton.addEventListener("click", event => {
597631
closeDialog(helpDialogId)
598632
})
599633

600-
const buttonBar = document.createElement("div");
601-
buttonBar.classList.add("button-center-wrapper");
602-
buttonBar.appendChild(closeButton);
634+
const buttonBar = createDialogButtonBar(closeButton);
603635

604636
const dialogContent = document.createElement("div");
605637
dialogContent.appendChild(controlsLabel)
@@ -748,6 +780,8 @@
748780
const fillBucket = 2;
749781

750782
let allowDrawing = false;
783+
let spectating = false;
784+
let spectateRequested = false;
751785

752786
//Initially, we require some values to avoid running into nullpointers
753787
//or undefined errors. The specific values don't really matter.
@@ -916,6 +950,57 @@
916950
return `<circle cx="` + circleRadius + `" cy="` + circleRadius + `" r="` + circleRadius + `" style="fill: ` + innerColorCSS + `; stroke: ` + outerColorCSS + `;"/>`;
917951
}
918952

953+
function toggleSpectate() {
954+
socket.send(JSON.stringify({
955+
type: "toggle-spectate",
956+
}));
957+
if (gameState === "ongoing") {
958+
}
959+
}
960+
961+
function setSpectateMode(requestedValue, spectatingValue) {
962+
const modeUnchanged = spectatingValue === spectating;
963+
const requestUnchanged = requestedValue === spectateRequested;
964+
if (modeUnchanged && requestUnchanged) {
965+
return;
966+
}
967+
968+
if (spectateRequested && !requestedValue && modeUnchanged) {
969+
showInfoDialog(
970+
'{{.Translation.Get "spectation-request-cancelled-title"}}',
971+
'{{.Translation.Get "spectation-request-cancelled-text"}}',
972+
"Okay");
973+
} else if (spectateRequested && !requestedValue && modeUnchanged) {
974+
showInfoDialog(
975+
'{{.Translation.Get "participation-request-cancelled-title"}}',
976+
'{{.Translation.Get "participation-request-cancelled-text"}}',
977+
"Okay");
978+
} else if (!spectateRequested && requestedValue && !spectatingValue) {
979+
showInfoDialog(
980+
'{{.Translation.Get "spectation-requested-title"}}',
981+
'{{.Translation.Get "spectation-requested-text"}}',
982+
"Okay");
983+
} else if (!spectateRequested && requestedValue && spectatingValue) {
984+
showInfoDialog(
985+
'{{.Translation.Get "participation-requested-title"}}',
986+
'{{.Translation.Get "participation-requested-text"}}',
987+
"Okay");
988+
} else if (spectatingValue && !spectating) {
989+
showInfoDialog(
990+
'{{.Translation.Get "now-spectating-title"}}',
991+
'{{.Translation.Get "now-spectating-text"}}',
992+
"Okay");
993+
} else if (!spectatingValue && spectating) {
994+
showInfoDialog(
995+
'{{.Translation.Get "now-participating-title"}}',
996+
'{{.Translation.Get "now-participating-text"}}',
997+
"Okay");
998+
}
999+
1000+
spectateRequested = requestedValue;
1001+
spectating = spectatingValue;
1002+
}
1003+
9191004
function toggleReadiness() {
9201005
socket.send(JSON.stringify({
9211006
type: "toggle-readiness",
@@ -975,13 +1060,23 @@
9751060
return false;
9761061
}
9771062

1063+
function setAllowDrawing(value) {
1064+
allowDrawing = value;
1065+
updateCursor();
1066+
1067+
if (allowDrawing) {
1068+
document.getElementById("toolbox").style.display = "flex";
1069+
} else {
1070+
document.getElementById("toolbox").style.display = "none";
1071+
}
1072+
}
1073+
9781074
function chooseWord(index) {
9791075
socket.send(JSON.stringify({
9801076
type: "choose-word",
9811077
data: index
9821078
}));
983-
allowDrawing = true;
984-
updateCursor();
1079+
setAllowDrawing(true);
9851080
wordDialog.style.visibility = "hidden";
9861081
}
9871082

@@ -1026,7 +1121,7 @@
10261121
} else if (parsed.type === "update-players") {
10271122
applyPlayers(parsed.data);
10281123
} else if (parsed.type === "name-change") {
1029-
const player = getPlayer(parsed.data.playerId);
1124+
const player = getCachedPlayer(parsed.data.playerId);
10301125
if (player !== null) {
10311126
player.name = parsed.data.playerName;
10321127
}
@@ -1047,7 +1142,7 @@
10471142
if (parsed.data === ownID) {
10481143
appendMessage("correct-guess-message", null, '{{.Translation.Get "correct-guess"}}');
10491144
} else {
1050-
const player = getPlayer(parsed.data)
1145+
const player = getCachedPlayer(parsed.data)
10511146
if (player !== null) {
10521147
appendMessage("correct-guess-message-other-player", null, '{{.Translation.Get "correct-guess-other-player"}}'.format(player.name));
10531148
}
@@ -1109,8 +1204,7 @@
11091204
//We clear this, since there's no word chosen right now.
11101205
wordContainer.innerHTML = "";
11111206

1112-
allowDrawing = false;
1113-
updateCursor();
1207+
setAllowDrawing(false);
11141208
} else if (parsed.type === "your-turn") {
11151209
playWav('{{.RootPath}}/resources/your-turn.wav?cache_bust={{.CacheBust}}');
11161210
//This dialog could potentially stay visible from last
@@ -1164,7 +1258,11 @@
11641258
}
11651259
}
11661260

1167-
function getPlayer(playerID) {
1261+
function getCachedPlayer(playerID) {
1262+
if (!cachedPlayers) {
1263+
return null;
1264+
}
1265+
11681266
for (let i = 0; i < cachedPlayers.length; i++) {
11691267
let player = cachedPlayers[i];
11701268
if (player.id === playerID) {
@@ -1197,7 +1295,7 @@
11971295

11981296
setRoundEndTime(ready.roundEndTime);
11991297
setUsernameLocally(ready.playerName);
1200-
allowDrawing = ready.allowDrawing;
1298+
setAllowDrawing(ready.allowDrawing);
12011299
round = ready.round;
12021300
rounds = ready.rounds;
12031301
gameState = ready.gameState;
@@ -1214,7 +1312,6 @@
12141312
if (ready.wordHints && ready.wordHints.length) {
12151313
applyWordHints(ready.wordHints);
12161314
}
1217-
updateCursor();
12181315

12191316
if (ready.gameState === "unstarted") {
12201317
startDialog.style.visibility = "visible";
@@ -1372,7 +1469,7 @@
13721469
let readyPlayersRequired = 0;
13731470

13741471
players.forEach(player => {
1375-
if (!player.connected) {
1472+
if (!player.connected || player.state === "spectating") {
13761473
return;
13771474
}
13781475

@@ -1399,13 +1496,33 @@
13991496
}
14001497

14011498
playerContainer.innerHTML = "";
1402-
cachedPlayers = players;
14031499
players.forEach(player => {
14041500
//We don't wanna show the disconnected players.
14051501
if (!player.connected) {
14061502
return;
14071503
}
14081504

1505+
if (player.id === ownID) {
1506+
setSpectateMode(player.spectateToggleRequested, player.state === "spectating");
1507+
}
1508+
1509+
const oldPlayer = getCachedPlayer(player.id);
1510+
if (oldPlayer && oldPlayer.state === "spectating" && player.state !== "spectating") {
1511+
appendMessage(
1512+
"system-message",
1513+
'{{.Translation.Get "system"}}',
1514+
`${player.name} is now participating`);
1515+
} else if (oldPlayer && oldPlayer.state !== "spectating" && player.state === "spectating") {
1516+
appendMessage(
1517+
"system-message",
1518+
'{{.Translation.Get "system"}}',
1519+
`${player.name} is now spectating`);
1520+
}
1521+
1522+
if (player.state === "spectating") {
1523+
return;
1524+
}
1525+
14091526
const playerDiv = document.createElement("div");
14101527

14111528
playerDiv.classList.add("player");
@@ -1464,6 +1581,10 @@
14641581

14651582
playerContainer.appendChild(playerDiv);
14661583
});
1584+
1585+
// We do this at the end, so we can access the old values while
1586+
// iterating over the new ones
1587+
cachedPlayers = players;
14671588
}
14681589

14691590
function createPlayerStateImageNode(path) {

internal/game/data.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -137,10 +137,11 @@ func (player *Player) GetUserSession() uuid.UUID {
137137
type PlayerState string
138138

139139
const (
140-
Guessing PlayerState = "guessing"
141-
Drawing PlayerState = "drawing"
142-
Standby PlayerState = "standby"
143-
Ready PlayerState = "ready"
140+
Guessing PlayerState = "guessing"
141+
Drawing PlayerState = "drawing"
142+
Standby PlayerState = "standby"
143+
Ready PlayerState = "ready"
144+
Spectating PlayerState = "spectating"
144145
)
145146

146147
// GetPlayer searches for a player, identifying them by usersession.

0 commit comments

Comments
 (0)