Improve public lobby join button UI/UX with animated three-dot indicator (#2670)

## Description:

This PR improves the public lobby join button UI by providing clearer,
state-aware feedback while a player is waiting to enter a match.

The button text now reflects two distinct phases of the join flow:
- **Waiting for players** while the lobby is filling
- **Starting game…** when the match is about to begin

This removes ambiguity caused by relying only on button color changes
and makes it immediately clear whether the join action has registered
and what stage the lobby is currently in.

### Demo
![Joining indicator
demo](https://media4.giphy.com/media/v1.Y2lkPTc5MGI3NjExdWg1aXNxenI4bmI2ZjlseTVnZGRvNG1wMW9icTgyMHM1NmVjY200MSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/ktmzjf0Lz55bfqbZUT/giphy.gif)

## How

- Replaces the static **“Join next game”** label with dynamic text based
on lobby state
- Shows **“Waiting for players”** with an animated three-dot indicator
while the lobby fills
- Switches to **“Starting game…”** shortly before the match begins
- Animation and state reset cleanly when leaving or cancelling
- Uses existing lobby timing and state, with no additional network calls

## Notes

- No CSS changes
- No behavioral changes to matchmaking logic
- Fully contained within `PublicLobby.ts`
- Added translation keys for the updated indicators to `en.json` (Rest
of language files will need to be updated)

## Testing notes

During local testing (single-player, local server), the button text
transitions as follows:

- Initial state (not clicked): **“Join next game”**
- After first click, the text briefly shows **“Starting game…”**
- It then switches to **“Waiting for players”** with the animated dots
- Shortly before the match starts, it switches back to **“Starting
game…”** and proceeds to start a solo game

Not sure why this flow happens in the testing environment:

**“Join next game”** ->**“Starting game…”** (brief) -> **“Waiting for
**players...”**** -> **“Starting game…”** (brief)

instead of just:

**“Join next game”** -> **“Waiting for **players...”**** -> **“Starting
game…”** (brief)

More testing is needed.

## Checklist

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [ ] I have added relevant tests to the test directory
- [ ] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
This commit is contained in:
Kyriakos Dimitriou
2025-12-24 20:17:22 +02:00
committed by GitHub
parent 86d1ac6c62
commit 28e22c9ca8
2 changed files with 48 additions and 24 deletions
+2
View File
@@ -268,6 +268,8 @@
"public_lobby": {
"join": "Join next Game",
"waiting": "players waiting",
"waiting_for_players": "Waiting for players",
"starting_game": "Starting game…",
"teams_Duos": "of 2 (Duos)",
"teams_Trios": "of 3 (Trios)",
"teams_Quads": "of 4 (Quads)",
+46 -24
View File
@@ -20,7 +20,10 @@ export class PublicLobby extends LitElement {
@state() public isLobbyHighlighted: boolean = false;
@state() private isButtonDebounced: boolean = false;
@state() private mapImages: Map<GameID, string> = new Map();
@state() private joiningDotIndex: number = 0;
private lobbiesInterval: number | null = null;
private joiningInterval: number | null = null;
private currLobby: GameInfo | null = null;
private debounceDelay: number = 750;
private lobbyIDToStart = new Map<GameID, number>();
@@ -45,20 +48,18 @@ export class PublicLobby extends LitElement {
clearInterval(this.lobbiesInterval);
this.lobbiesInterval = null;
}
this.stopJoiningAnimation();
}
private async fetchAndUpdateLobbies(): Promise<void> {
try {
this.lobbies = await this.fetchLobbies();
this.lobbies.forEach((l) => {
// Store the start time on first fetch because endpoint is cached, causing
// the time to appear irregular.
if (!this.lobbyIDToStart.has(l.gameID)) {
const msUntilStart = l.msUntilStart ?? 0;
this.lobbyIDToStart.set(l.gameID, msUntilStart + Date.now());
}
// Load map image if not already loaded
if (l.gameConfig && !this.mapImages.has(l.gameID)) {
this.loadMapImage(l.gameID, l.gameConfig.gameMap);
}
@@ -70,7 +71,6 @@ export class PublicLobby extends LitElement {
private async loadMapImage(gameID: GameID, gameMap: string) {
try {
// Convert string to GameMapType enum value
const mapType = gameMap as GameMapType;
const data = terrainMapFileLoader.getMapData(mapType);
this.mapImages.set(gameID, await data.webpPath());
@@ -106,6 +106,7 @@ export class PublicLobby extends LitElement {
public stop() {
if (this.lobbiesInterval !== null) {
this.isLobbyHighlighted = false;
this.stopJoiningAnimation();
clearInterval(this.lobbiesInterval);
this.lobbiesInterval = null;
}
@@ -115,13 +116,11 @@ export class PublicLobby extends LitElement {
if (this.lobbies.length === 0) return html``;
const lobby = this.lobbies[0];
if (!lobby?.gameConfig) {
return;
}
if (!lobby?.gameConfig) return html``;
const start = this.lobbyIDToStart.get(lobby.gameID) ?? 0;
const timeRemaining = Math.max(0, Math.floor((start - Date.now()) / 1000));
// Format time to show minutes and seconds
const isStarting = timeRemaining <= 2;
const timeDisplay = renderDuration(timeRemaining);
const teamCount =
@@ -177,17 +176,26 @@ export class PublicLobby extends LitElement {
>
<div>
<div class="text-lg md:text-2xl font-semibold">
${translateText("public_lobby.join")}
${this.currLobby
? isStarting
? html`${translateText("public_lobby.starting_game")}`
: html`${translateText("public_lobby.waiting_for_players")}
${[0, 1, 2]
.map((i) => (i === this.joiningDotIndex ? "•" : "·"))
.join("")}`
: translateText("public_lobby.join")}
</div>
<div class="text-md font-medium text-white-400">
<span class="text-sm text-red-800 bg-white rounded-sm px-1 mr-1"
>${fullModeLabel}</span
>
<span
>${translateText(
`map.${lobby.gameConfig.gameMap.toLowerCase().replace(/[\s.]+/g, "")}`,
)}</span
>
<span class="text-sm text-red-800 bg-white rounded-sm px-1 mr-1">
${fullModeLabel}
</span>
<span>
${translateText(
`map.${lobby.gameConfig.gameMap
.toLowerCase()
.replace(/[\s.]+/g, "")}`,
)}
</span>
</div>
</div>
@@ -205,6 +213,24 @@ export class PublicLobby extends LitElement {
leaveLobby() {
this.isLobbyHighlighted = false;
this.currLobby = null;
this.stopJoiningAnimation();
}
private startJoiningAnimation() {
if (this.joiningInterval !== null) return;
this.joiningDotIndex = 0;
this.joiningInterval = window.setInterval(() => {
this.joiningDotIndex = (this.joiningDotIndex + 1) % 3;
}, 500);
}
private stopJoiningAnimation() {
if (this.joiningInterval !== null) {
clearInterval(this.joiningInterval);
this.joiningInterval = null;
}
this.joiningDotIndex = 0;
}
private getTeamSize(
@@ -270,14 +296,9 @@ export class PublicLobby extends LitElement {
}
private lobbyClicked(lobby: GameInfo) {
if (this.isButtonDebounced) {
return;
}
if (this.isButtonDebounced) return;
// Set debounce state
this.isButtonDebounced = true;
// Reset debounce after delay
setTimeout(() => {
this.isButtonDebounced = false;
}, this.debounceDelay);
@@ -285,6 +306,7 @@ export class PublicLobby extends LitElement {
if (this.currLobby === null) {
this.isLobbyHighlighted = true;
this.currLobby = lobby;
this.startJoiningAnimation();
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {