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);
}