From 4463236e8bec1c268dec6c77f881212044f1df07 Mon Sep 17 00:00:00 2001 From: Abdallah Bahrawi <140177728+abdallahbahrawi1@users.noreply.github.com> Date: Thu, 20 Nov 2025 05:27:41 +0200 Subject: [PATCH] Lobby Team Preview UI (#2444) Resolves #1092 ## Description: Added a team preview to the Host Lobby: players listed on the left, teams on the right in two scrollable columns with color dots matching in-game colors. Implemented accurate server-parity team assignment (including clan grouping). Screenshots: Screenshot 2025-11-13 173721 Screenshot 2025-11-13 175400 ## Please complete the following: - [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 - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: abodcraft1 --------- Co-authored-by: Evan --- resources/lang/en.json | 6 +- src/client/HostLobbyModal.ts | 29 +-- src/client/components/LobbyTeamView.ts | 288 +++++++++++++++++++++++++ src/core/configuration/PastelTheme.ts | 3 - 4 files changed, 301 insertions(+), 25 deletions(-) create mode 100644 src/client/components/LobbyTeamView.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 77e6b24b5..0897c7718 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -277,7 +277,11 @@ "waiting": "Waiting for players...", "random_spawn": "Random spawn", "start": "Start Game", - "host_badge": "Host" + "host_badge": "Host", + "assigned_teams": "Assigned Teams", + "empty_teams": "Empty Teams", + "empty_team": "Empty", + "remove_player": "Remove {{username}}" }, "team_colors": { "red": "Red", diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index ccb01843c..9ace197cf 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -25,6 +25,7 @@ import { import { generateID } from "../core/Util"; import "./components/baseComponents/Modal"; import "./components/Difficulties"; +import "./components/LobbyTeamView"; import "./components/Maps"; import { JoinLobbyEvent } from "./Main"; import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions"; @@ -554,27 +555,13 @@ export class HostLobbyModal extends LitElement { } -
- ${this.clients.map( - (client) => html` - - ${client.username} - ${client.clientID === this.lobbyCreatorClientID - ? html`(${translateText("host_modal.host_badge")})` - : html` - - `} - - `, - )} + this.kickPlayer(clientID)} + >
diff --git a/src/client/components/LobbyTeamView.ts b/src/client/components/LobbyTeamView.ts new file mode 100644 index 000000000..0679108f8 --- /dev/null +++ b/src/client/components/LobbyTeamView.ts @@ -0,0 +1,288 @@ +import { LitElement, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { repeat } from "lit/directives/repeat.js"; +import { PastelTheme } from "../../core/configuration/PastelTheme"; +import { + ColoredTeams, + Duos, + GameMode, + HumansVsNations, + PlayerInfo, + PlayerType, + Quads, + Team, + Trios, +} from "../../core/game/Game"; +import { assignTeams } from "../../core/game/TeamAssignment"; +import { ClientInfo, TeamCountConfig } from "../../core/Schemas"; +import { translateText } from "../Utils"; + +export interface TeamPreviewData { + team: Team; + players: ClientInfo[]; +} + +@customElement("lobby-team-view") +export class LobbyTeamView extends LitElement { + @property({ type: String }) gameMode: GameMode = GameMode.FFA; + @property({ type: Array }) clients: ClientInfo[] = []; + @state() private teamPreview: TeamPreviewData[] = []; + @state() private teamMaxSize: number = 0; + @property({ type: String }) lobbyCreatorClientID: string = ""; + @property({ attribute: "team-count" }) teamCount: TeamCountConfig = 2; + @property({ type: Function }) onKickPlayer?: (clientID: string) => void; + + private theme: PastelTheme = new PastelTheme(); + @state() private showTeamColors: boolean = false; + + willUpdate(changedProperties: Map) { + // Recompute team preview when relevant properties change + if ( + changedProperties.has("gameMode") || + changedProperties.has("clients") || + changedProperties.has("teamCount") + ) { + this.computeTeamPreview(); + this.showTeamColors = this.getTeamList().length <= 7; + } + } + + render() { + return html`
+ ${this.gameMode === GameMode.Team + ? this.renderTeamMode() + : this.renderFreeForAll()} +
`; + } + + createRenderRoot() { + return this; + } + + private renderTeamMode() { + const active = this.teamPreview.filter((t) => t.players.length > 0); + const empty = this.teamPreview.filter((t) => t.players.length === 0); + return html`
+
+
+ ${translateText("host_modal.players")} +
+ ${repeat( + this.clients, + (c) => c.clientID ?? c.username, + (client) => + html`
+ ${client.username} +
`, + )} +
+
+
+
+ ${translateText("host_modal.assigned_teams")} +
+
+ ${repeat( + active, + (p) => p.team, + (preview) => this.renderTeamCard(preview, false), + )} +
+
+
+
+ ${translateText("host_modal.empty_teams")} +
+
+ ${repeat( + empty, + (p) => p.team, + (preview) => this.renderTeamCard(preview, true), + )} +
+
+
+
`; + } + + private renderFreeForAll() { + return html`${repeat( + this.clients, + (c) => c.clientID ?? c.username, + (client) => + html` + ${client.username} + ${client.clientID === this.lobbyCreatorClientID + ? html`(${translateText("host_modal.host_badge")})` + : html``} + `, + )} `; + } + + private renderTeamCard(preview: TeamPreviewData, isEmpty: boolean = false) { + return html` +
+
+ ${this.showTeamColors + ? html`` + : null} + ${preview.team} + ${preview.players.length}/${this.teamMaxSize} +
+
+ ${isEmpty + ? html`
+ ${translateText("host_modal.empty_team")} +
` + : repeat( + preview.players, + (p) => p.clientID ?? p.username, + (p) => + html`
+ ${p.username} + ${p.clientID === this.lobbyCreatorClientID + ? html`(${translateText("host_modal.host_badge")})` + : html``} +
`, + )} +
+
+ `; + } + + private getTeamList(): Team[] { + if (this.gameMode !== GameMode.Team) return []; + const playerCount = this.clients.length; + const config = this.teamCount; + + if (config === HumansVsNations) { + return [ColoredTeams.Humans, ColoredTeams.Nations]; + } + + let numTeams: number; + if (typeof config === "number") { + numTeams = Math.max(2, config); + } else { + const divisor = + config === Duos ? 2 : config === Trios ? 3 : config === Quads ? 4 : 2; + numTeams = Math.max(2, Math.ceil(playerCount / divisor)); + } + + if (numTeams < 8) { + const ordered: Team[] = [ + ColoredTeams.Red, + ColoredTeams.Blue, + ColoredTeams.Yellow, + ColoredTeams.Green, + ColoredTeams.Purple, + ColoredTeams.Orange, + ColoredTeams.Teal, + ]; + return ordered.slice(0, numTeams); + } + + return Array.from({ length: numTeams }, (_, i) => `Team ${i + 1}`); + } + + private teamHeaderColor(team: Team): string { + try { + return this.theme.teamColor(team).toHex(); + } catch { + return "#3b3f46"; // Default gray for unknown teams + } + } + + private computeTeamPreview() { + if (this.gameMode !== GameMode.Team) { + this.teamPreview = []; + this.teamMaxSize = 0; + return; + } + const teams = this.getTeamList(); + + // HumansVsNations: show all clients under Humans initially + if (this.teamCount === HumansVsNations) { + this.teamMaxSize = this.clients.length; + this.teamPreview = [ + { team: ColoredTeams.Humans, players: [...this.clients] }, + { team: ColoredTeams.Nations, players: [] }, + ]; + return; + } + + const players = this.clients.map( + (c) => + new PlayerInfo(c.username, PlayerType.Human, c.clientID, c.clientID), + ); + const assignment = assignTeams(players, teams); + const buckets = new Map(); + for (const t of teams) buckets.set(t, []); + + for (const [p, team] of assignment.entries()) { + if (team === "kicked") continue; + const bucket = buckets.get(team); + if (!bucket) continue; + const client = + this.clients.find((c) => c.clientID === p.clientID) ?? + this.clients.find((c) => c.username === p.name); + if (client) bucket.push(client); + } + + // Compute per-team capacity safely and align with common team sizes + if (this.teamCount === Duos) { + this.teamMaxSize = 2; + } else if (this.teamCount === Trios) { + this.teamMaxSize = 3; + } else if (this.teamCount === Quads) { + this.teamMaxSize = 4; + } else { + // Fallback: divide players across teams; guard against 0 and empty lobbies + this.teamMaxSize = Math.max( + 1, + Math.ceil(this.clients.length / teams.length), + ); + } + this.teamPreview = teams.map((t) => ({ + team: t, + players: buckets.get(t) ?? [], + })); + } +} diff --git a/src/core/configuration/PastelTheme.ts b/src/core/configuration/PastelTheme.ts index b798423fa..e024afc32 100644 --- a/src/core/configuration/PastelTheme.ts +++ b/src/core/configuration/PastelTheme.ts @@ -7,10 +7,7 @@ import { ColorAllocator } from "./ColorAllocator"; import { botColors, fallbackColors, humanColors, nationColors } from "./Colors"; import { Theme } from "./Config"; -type ColorCache = Map; - export class PastelTheme implements Theme { - private borderColorCache: ColorCache = new Map(); private rand = new PseudoRandom(123); private humanColorAllocator = new ColorAllocator(humanColors, fallbackColors); private botColorAllocator = new ColorAllocator(botColors, botColors);