From 46d5aed44c466c97238f3c21021f36075b3e64e7 Mon Sep 17 00:00:00 2001 From: Andrio Celos Date: Tue, 21 Nov 2023 10:42:38 +1100 Subject: [PATCH] Show disconnected players; allow leaving games before start --- TableturfBattleClient/assets/wifi-off.svg | 4 + TableturfBattleClient/src/GameVariables.ts | 3 - TableturfBattleClient/src/Pages/GamePage.ts | 3 +- TableturfBattleClient/src/Pages/LobbyPage.ts | 108 ++++++++++++++---- TableturfBattleClient/src/Player.ts | 1 + TableturfBattleClient/src/PlayerBar.ts | 15 ++- TableturfBattleClient/src/ReplayLoader.ts | 2 + TableturfBattleClient/src/WinCounter.ts | 2 +- TableturfBattleClient/src/app.ts | 20 +++- TableturfBattleClient/tableturf.css | 53 ++++++++- TableturfBattleServer/Game.cs | 25 +++- TableturfBattleServer/Player.cs | 18 +++ TableturfBattleServer/Program.cs | 2 +- .../TableturfWebSocketBehaviour.cs | 5 +- 14 files changed, 221 insertions(+), 40 deletions(-) create mode 100644 TableturfBattleClient/assets/wifi-off.svg diff --git a/TableturfBattleClient/assets/wifi-off.svg b/TableturfBattleClient/assets/wifi-off.svg new file mode 100644 index 0000000..8b421e7 --- /dev/null +++ b/TableturfBattleClient/assets/wifi-off.svg @@ -0,0 +1,4 @@ + + + + diff --git a/TableturfBattleClient/src/GameVariables.ts b/TableturfBattleClient/src/GameVariables.ts index 1a93235..eedda31 100644 --- a/TableturfBattleClient/src/GameVariables.ts +++ b/TableturfBattleClient/src/GameVariables.ts @@ -45,8 +45,5 @@ let currentReplay: { watchingPlayer: number } | null = null; -const playerList = document.getElementById('playerList')!; -const playerListItems: HTMLElement[] = [ ]; - const canPlayCard = [ false, false, false, false ]; const canPlayCardAsSpecialAttack = [ false, false, false, false ]; diff --git a/TableturfBattleClient/src/Pages/GamePage.ts b/TableturfBattleClient/src/Pages/GamePage.ts index 79acfd1..c765911 100644 --- a/TableturfBattleClient/src/Pages/GamePage.ts +++ b/TableturfBattleClient/src/Pages/GamePage.ts @@ -498,6 +498,7 @@ function loadPlayers(players: Player[]) { const player = players[i]; currentGame!.game.players[i] = players[i]; playerBars[i].name = player.name; + playerBars[i].setOnline(player.isOnline); playerBars[i].winCounter.wins = players[i].gamesWon; updateStats(i, scores); } @@ -509,7 +510,7 @@ function loadPlayers(players: Player[]) { } function updateColours() { - if (currentGame == null) return; + if (currentGame == null || currentGame.game.players.length == 0) return; for (let i = 0; i < currentGame.game.players.length; i++) { if (currentGame.game.players[i].colour.r > 0 || currentGame.game.players[i].colour.g > 0 || currentGame.game.players[i].colour.b > 0) { setColour(i, 0, currentGame.game.players[i].colour); diff --git a/TableturfBattleClient/src/Pages/LobbyPage.ts b/TableturfBattleClient/src/Pages/LobbyPage.ts index 996f2ca..d26f449 100644 --- a/TableturfBattleClient/src/Pages/LobbyPage.ts +++ b/TableturfBattleClient/src/Pages/LobbyPage.ts @@ -1,4 +1,9 @@ +const playerList = document.getElementById('playerList')!; +const playerListSlots: HTMLElement[] = [ ]; +const playerListNames: HTMLElement[] = [ ]; const lobbyWinCounters: WinCounter[] = [ ]; +const playerListItemsToRemove: HTMLElement[] = [ ]; +let playerListItemToRemove: HTMLElement | null = null; const stageButtons = new CheckButtonGroup(document.getElementById('stageList')!); const shareLinkButton = document.getElementById('shareLinkButton') as HTMLButtonElement; @@ -153,18 +158,20 @@ qrCodeDialog.addEventListener('click', e => { }); function lobbyResetSlots() { - if (!currentGame) throw new Error('No current game'); - for (const li of playerListItems) - playerList.removeChild(li); - playerListItems.splice(0); + if (!currentGame) throw new TypeError('No current game'); + playerListSlots.splice(0); + playerListNames.splice(0); lobbyWinCounters.splice(0); + clearChildren(playerList); for (let i = 0; i < currentGame.game.maxPlayers; i++) { - var el = document.createElement('li'); - el.className = 'empty'; - el.innerText = 'Waiting...'; - playerListItems.push(el); + const el = document.createElement('li'); + const placeholder = document.createElement('div'); + placeholder.className = 'placeholder'; + placeholder.innerText = 'Waiting...'; playerList.appendChild(el); + el.appendChild(placeholder); + playerListSlots.push(el); } lobbyLockSettings(currentGame.me?.playerIndex != 0); @@ -175,33 +182,92 @@ function lobbyLockSettings(lock: boolean) { } function clearReady() { - if (!currentGame) throw new Error('No current game'); + if (!currentGame) throw new TypeError('No current game'); lobbyStageSubmitButton.disabled = false; stageSelectionFormLoadingSection.hidden = true; for (var i = 0; i < currentGame.game.players.length; i++) { currentGame.game.players[i].isReady = false; - playerListItems[i].className = 'filled'; + playerListNames[i].classList.remove('ready'); } } -function lobbyAddPlayer(playerIndex: number) { - if (!currentGame) throw new Error('No current game'); - const listItem = playerListItems[playerIndex]; +function lobbyAddPlayer() { + if (!currentGame) throw new TypeError('No current game'); + + if (playerListItemToRemove) { + playerListItemToRemove.removeEventListener('animationend', playerListItem_animationEnd); + playerListItem_animationEnd(); + } + + const playerIndex = playerListNames.length; + const slot = playerListSlots[playerIndex]; const player = currentGame.game.players[playerIndex]; - listItem.innerText = player.name; - listItem.className = player.isReady ? 'filled ready' : 'filled'; const el = document.createElement('div'); - el.className = 'wins'; - el.title = 'Battles won'; - listItem.appendChild(el); - const winCounter = new WinCounter(el); - winCounter.wins = currentGame.game.players[playerIndex].gamesWon; + el.classList.add('filled'); + if (player.isReady) el.classList.add('ready'); + if (!player.isOnline) el.classList.add('disconnected'); + el.innerText = player.name; + slot.appendChild(el); + playerListNames.push(el); + + const el2 = document.createElement('img'); + el2.src = 'assets/wifi-off.svg'; + el2.className = 'disconnectedIcon'; + el2.title = 'Disconnected'; + el.appendChild(el2); + + const el3 = document.createElement('div'); + el3.className = 'wins'; + el3.title = 'Battles won'; + el.appendChild(el3); + + const winCounter = new WinCounter(el3); + winCounter.wins = player.gamesWon; lobbyWinCounters.push(winCounter); } +function lobbyRemovePlayer(playerIndex: number) { + if (!currentGame) throw new TypeError('No current game'); + + // Animate the leaving player and all entries below them to mimic the original game. + for (let i = playerIndex; i < playerListNames.length; i++) + (playerListSlots[i].lastElementChild).classList.add('removed'); + + const el = playerListSlots[playerIndex].lastElementChild; + el.classList.add('removed'); + playerListItemsToRemove.push(el); + + if (playerListItemToRemove) + playerListItemToRemove.removeEventListener('animationend', playerListItem_animationEnd); + + playerListItemToRemove = el; + el.addEventListener('animationend', playerListItem_animationEnd); + playerListNames.splice(playerIndex, 1); +} + +function playerListItem_animationEnd() { + for (const el of playerListItemsToRemove) + el.parentElement!.removeChild(el); + playerListItemsToRemove.splice(0); + playerListItemToRemove = null; + + for (let i = 0; i < playerListNames.length; i++) { + playerListNames[i].classList.remove('removed'); + if (playerListNames[i].parentElement != playerListSlots[i]) { + playerListNames[i].parentElement!.removeChild(playerListNames[i]); + playerListSlots[i].appendChild(playerListNames[i]); + } + } +} + function lobbySetReady(playerIndex: number) { - playerListItems[playerIndex].className = 'filled ready'; + playerListNames[playerIndex].classList.add('ready'); +} + +function lobbySetOnline(playerIndex: number, isOnline: boolean) { + if (isOnline) playerListNames[playerIndex].classList.remove('disconnected'); + else playerListNames[playerIndex].classList.add('disconnected'); } function initDeckSelection() { diff --git a/TableturfBattleClient/src/Player.ts b/TableturfBattleClient/src/Player.ts index b7aa4dd..2a12102 100644 --- a/TableturfBattleClient/src/Player.ts +++ b/TableturfBattleClient/src/Player.ts @@ -2,6 +2,7 @@ interface Player { name: string; specialPoints: number; isReady: boolean; + isOnline: boolean; colour: Colour; specialColour: Colour; specialAccentColour: Colour; diff --git a/TableturfBattleClient/src/PlayerBar.ts b/TableturfBattleClient/src/PlayerBar.ts index 02a98f7..f169d3c 100644 --- a/TableturfBattleClient/src/PlayerBar.ts +++ b/TableturfBattleClient/src/PlayerBar.ts @@ -48,7 +48,15 @@ class PlayerBar { } get name() { return this.nameElement.innerText; } - set name(value: string) { this.nameElement.innerText = value; } + set name(value: string) { + this.nameElement.innerText = value; + + const el2 = document.createElement('img'); + el2.src = 'assets/wifi-off.svg'; + el2.className = 'disconnectedIcon'; + el2.title = 'Disconnected'; + this.nameElement.appendChild(el2); + } get points() { return parseInt(this.pointsElement.innerText); } set points(value: number) { this.pointsElement.innerText = value.toString(); } @@ -116,4 +124,9 @@ class PlayerBar { this.element.hidden = !value; this.pointsContainer.hidden = !value; } + + setOnline(value: boolean) { + if (value) this.element.classList.remove('disconnected'); + else this.element.classList.add('disconnected'); + } } diff --git a/TableturfBattleClient/src/ReplayLoader.ts b/TableturfBattleClient/src/ReplayLoader.ts index 64d0310..8afa0c8 100644 --- a/TableturfBattleClient/src/ReplayLoader.ts +++ b/TableturfBattleClient/src/ReplayLoader.ts @@ -55,6 +55,7 @@ class ReplayLoader { name: this.readString(), specialPoints: 0, isReady: false, + isOnline: true, colour, specialColour, specialAccentColour, @@ -226,6 +227,7 @@ class ReplayLoader { name: this.readString(len), specialPoints: 0, isReady: false, + isOnline: true, colour, specialColour, specialAccentColour, diff --git a/TableturfBattleClient/src/WinCounter.ts b/TableturfBattleClient/src/WinCounter.ts index ba14eb1..5683d28 100644 --- a/TableturfBattleClient/src/WinCounter.ts +++ b/TableturfBattleClient/src/WinCounter.ts @@ -2,7 +2,7 @@ class WinCounter { readonly parent: HTMLElement; private _wins: number = 0; - constructor(element: HTMLDivElement) { + constructor(element: HTMLElement) { this.parent = element; } diff --git a/TableturfBattleClient/src/app.ts b/TableturfBattleClient/src/app.ts index a4fff0c..57be347 100644 --- a/TableturfBattleClient/src/app.ts +++ b/TableturfBattleClient/src/app.ts @@ -128,7 +128,7 @@ function onGameStateChange(game: any, playerData: PlayerData | null) { gamePage.dataset.myPlayerIndex = playerData ? playerData.playerIndex.toString() : ''; gamePage.dataset.uiBaseColourIsSpecialColour = (userConfig.colourLock ? (playerData?.playerIndex ?? 0) != 1 - : game.players[playerData?.playerIndex ?? 0].uiBaseColourIsSpecialColour ?? true).toString(); + : game.players[playerData?.playerIndex ?? 0]?.uiBaseColourIsSpecialColour ?? true).toString(); if (game.state != GameState.WaitingForPlayers) lobbyLockSettings(true); @@ -274,7 +274,7 @@ function setupWebSocket(gameID: string) { lobbyResetSlots(); for (let i = 0; i < currentGame.game.players.length; i++) - lobbyAddPlayer(i); + lobbyAddPlayer(); onGameSettingsChange(); for (let i = 0; i < playerBars.length; i++) { @@ -318,7 +318,7 @@ function setupWebSocket(gameID: string) { } } else { if (currentGame == null) { - communicationError(); + if (payload.event != 'playerOnline') communicationError(); return; } switch (payload.event) { @@ -329,7 +329,14 @@ function setupWebSocket(gameID: string) { case 'join': if (payload.data.playerIndex == currentGame.game.players.length) { currentGame.game.players.push(payload.data.player); - lobbyAddPlayer(payload.data.playerIndex); + lobbyAddPlayer(); + } else + communicationError(); + break; + case 'leave': + if (payload.data.playerIndex < currentGame.game.players.length) { + currentGame.game.players.splice(payload.data.playerIndex, 1); + lobbyRemovePlayer(payload.data.playerIndex); } else communicationError(); @@ -344,6 +351,11 @@ function setupWebSocket(gameID: string) { showReady(payload.data.playerIndex); break; + case 'playerOnline': + currentGame.game.players[payload.data.playerIndex].isOnline = payload.data.isOnline; + lobbySetOnline(payload.data.playerIndex, payload.data.isOnline); + playerBars[payload.data.playerIndex].setOnline(payload.data.isOnline); + break; case 'stateChange': clearReady(); onGameStateChange(payload.data, payload.playerData); diff --git a/TableturfBattleClient/tableturf.css b/TableturfBattleClient/tableturf.css index c1cb0c8..3296fd4 100644 --- a/TableturfBattleClient/tableturf.css +++ b/TableturfBattleClient/tableturf.css @@ -153,18 +153,35 @@ footer { } #playerList li { - width: calc(100% - 3em); + width: calc(100% - 2em); margin: 0.5em 1em; - background: #111; - border-radius: 0.5em; + position: relative; +} + +#playerList li > div { padding: 0.5em; + border-radius: 0.5em; + background: #111; text-shadow: 1px 1px black; + box-sizing: border-box; +} + +#playerList li .placeholder { + user-select: none; } #playerList .filled { + position: absolute; + left: 0; + top: 0; + width: 100%; + bottom: 0; background: var(--theme-colour); - position: relative; - animation: 0.33s linear playerListFlyIn; + animation: 0.33s linear forwards playerListFlyIn; +} + +#playerList .removed { + animation: 0.33s linear forwards playerListFlyOut; } #playerList .ready::after { @@ -176,11 +193,31 @@ footer { font-size: x-large; } +#playerList .disconnected { + color: darkgrey; +} + @keyframes playerListFlyIn { - from { left: -100%; } + from { left: -120%; } to { left: 0; } } +@keyframes playerListFlyOut { + from { left: 0%; } + to { left: -120%; } +} + +.disconnectedIcon { + display: none; + height: 1.5rem; + margin-left: 0.5em; + vertical-align: middle; +} + +.disconnected .disconnectedIcon { + display: inline; +} + .wins { display: flex; } @@ -1167,6 +1204,10 @@ rect.special, g.specialCost rect { margin: 0.5em 0; } +.disconnected .name { + color: darkgrey; +} + .specialPoints div { display: inline-block; width: 1.25em; diff --git a/TableturfBattleServer/Game.cs b/TableturfBattleServer/Game.cs index 1b0be28..41e2ef3 100644 --- a/TableturfBattleServer/Game.cs +++ b/TableturfBattleServer/Game.cs @@ -2,6 +2,7 @@ using System.Net; using System.Text; using Newtonsoft.Json; +using WebSocketSharp.Server; namespace TableturfBattleServer; public class Game { @@ -460,7 +461,7 @@ public class Game { } } else if (this.TurnTimeLeft != null) { --this.TurnTimeLeft; - if (this.TurnTimeLeft <= -3) { // Add a small grace period to account for network lag. + if (this.TurnTimeLeft <= -3 || (this.TurnTimeLeft <= 0 && this.Players.All(p => p.IsReady || !p.IsOnline))) { // Add a small grace period to account for network lag for online players. for (var i = 0; i < this.Players.Count; i++) { var player = this.Players[i]; if (player.Move == null) { @@ -594,6 +595,28 @@ public class Game { internal void SendPlayerReadyEvent(int playerIndex, bool isTimeout) => this.SendEvent("playerReady", new { playerIndex, isTimeout }, false); + internal void AddConnection(int playerIndex, TableturfWebSocketBehaviour connection) { + var player = this.Players[playerIndex]; + player.AddConnection(connection); + if (!player.IsOnline) { + player.DisconnectedAt = null; + this.SendEvent("playerOnline", new { playerIndex, player.IsOnline }, false); + } + } + internal void RemoveConnection(Player player, TableturfWebSocketBehaviour connection) { + var playerIndex = this.Players.IndexOf(player); + player.RemoveConnection(connection); + if (player.IsOnline && player.Connections.Count == 0) { + if (this.State == GameState.WaitingForPlayers) { + this.Players.RemoveAt(playerIndex); + this.SendEvent("leave", new { playerIndex }, false); + } else { + player.DisconnectedAt = DateTime.UtcNow; + this.SendEvent("playerOnline", new { playerIndex, player.IsOnline }, false); + } + } + } + internal void SendEvent(string eventType, T data, bool includePlayerData) { foreach (var session in Program.httpServer!.WebSocketServices.Hosts.First().Sessions.Sessions) { if (session is TableturfWebSocketBehaviour behaviour && behaviour.GameID == this.ID) { diff --git a/TableturfBattleServer/Player.cs b/TableturfBattleServer/Player.cs index c8166cd..153cb02 100644 --- a/TableturfBattleServer/Player.cs +++ b/TableturfBattleServer/Player.cs @@ -10,6 +10,12 @@ public class Player { public Colour SpecialAccentColour { get; set; } public bool UIBaseColourIsSpecialColour { get; set; } + [JsonIgnore] + internal List Connections { get; } = new(); + [JsonIgnore] + public DateTime? DisconnectedAt { get; set; } + public bool IsOnline => this.DisconnectedAt == null; + public StageSelectionPrompt? StageSelectionPrompt { get; set; } [JsonIgnore] @@ -85,4 +91,16 @@ public class Player { } return -1; } + + internal void AddConnection(TableturfWebSocketBehaviour connection) { + lock (this.Connections) { + this.Connections.Add(connection); + } + } + + internal void RemoveConnection(TableturfWebSocketBehaviour connection) { + lock (this.Connections) { + this.Connections.Remove(connection); + } + } } diff --git a/TableturfBattleServer/Program.cs b/TableturfBattleServer/Program.cs index 3092265..c260e34 100644 --- a/TableturfBattleServer/Program.cs +++ b/TableturfBattleServer/Program.cs @@ -1,4 +1,4 @@ -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Net; using System.Reflection; diff --git a/TableturfBattleServer/TableturfWebSocketBehaviour.cs b/TableturfBattleServer/TableturfWebSocketBehaviour.cs index a6e9058..6311816 100644 --- a/TableturfBattleServer/TableturfWebSocketBehaviour.cs +++ b/TableturfBattleServer/TableturfWebSocketBehaviour.cs @@ -20,20 +20,23 @@ internal class TableturfWebSocketBehaviour : WebSocketBehavior { // Send an initial state payload. if (Program.TryGetGame(this.GameID, out var game)) { - this.Game = game; DTO.PlayerData? playerData = null; for (int i = 0; i < game.Players.Count; i++) { var player = game.Players[i]; if (player.Token == this.ClientToken) { this.Player = player; playerData = new(i, player); + game.AddConnection(i, this); break; } } + this.Game = game; this.Send(JsonUtils.Serialise(new DTO.WebSocketPayloadWithPlayerData("sync", game, playerData))); } else this.Send(JsonUtils.Serialise(new DTO.WebSocketPayloadWithPlayerData("sync", null, null))); } + protected override void OnClose(WebSocketSharp.CloseEventArgs e) => this.Game?.RemoveConnection(this.Player, this); + internal void SendInternal(string data) => this.Send(data); }