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:

<img width="817" height="519" alt="Screenshot 2025-11-13 173721"
src="https://github.com/user-attachments/assets/ec646238-7efa-4c8f-9c0a-171b61fd3f20"
/>

<img width="762" height="425" alt="Screenshot 2025-11-13 175400"
src="https://github.com/user-attachments/assets/ebdccb80-4c07-41d5-8f69-3ea983d4b243"
/>


## 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 <evanpelle@gmail.com>
This commit is contained in:
Abdallah Bahrawi
2025-11-20 05:27:41 +02:00
committed by GitHub
parent 599342995c
commit 4463236e8b
4 changed files with 301 additions and 25 deletions
+5 -1
View File
@@ -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",
+8 -21
View File
@@ -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 {
}
</div>
<div class="players-list">
${this.clients.map(
(client) => html`
<span class="player-tag">
${client.username}
${client.clientID === this.lobbyCreatorClientID
? html`<span class="host-badge"
>(${translateText("host_modal.host_badge")})</span
>`
: html`
<button
class="remove-player-btn"
@click=${() => this.kickPlayer(client.clientID)}
title="Remove ${client.username}"
>
×
</button>
`}
</span>
`,
)}
<lobby-team-view
.gameMode=${this.gameMode}
.clients=${this.clients}
.lobbyCreatorClientID=${this.lobbyCreatorClientID}
.teamCount=${this.teamCount}
.onKickPlayer=${(clientID: string) => this.kickPlayer(clientID)}
></lobby-team-view>
</div>
<div class="start-game-button-container">
+288
View File
@@ -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<string, any>) {
// 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`<div class="players-list">
${this.gameMode === GameMode.Team
? this.renderTeamMode()
: this.renderFreeForAll()}
</div>`;
}
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` <div
class="flex flex-col md:flex-row gap-3 md:gap-4 items-stretch max-h-[65vh]"
>
<div
class="w-full md:w-60 bg-gray-800 p-2 border border-gray-700 rounded-lg max-h-40 md:max-h-[65vh] overflow-auto"
>
<div class="font-bold mb-1.5 text-gray-300 text-sm">
${translateText("host_modal.players")}
</div>
${repeat(
this.clients,
(c) => c.clientID ?? c.username,
(client) =>
html`<div class="px-2 py-1 rounded bg-gray-700/70 mb-1 text-xs">
${client.username}
</div>`,
)}
</div>
<div
class="flex-1 flex flex-col gap-3 md:gap-4 overflow-auto max-h-[65vh] md:pr-1"
>
<div>
<div class="font-semibold text-gray-200 mb-1 text-sm">
${translateText("host_modal.assigned_teams")}
</div>
<div class="w-full grid grid-cols-1 sm:grid-cols-2 gap-2 md:gap-3">
${repeat(
active,
(p) => p.team,
(preview) => this.renderTeamCard(preview, false),
)}
</div>
</div>
<div>
<div class="font-semibold text-gray-200 mb-1 text-sm">
${translateText("host_modal.empty_teams")}
</div>
<div class="w-full grid grid-cols-1 sm:grid-cols-2 gap-2 md:gap-3">
${repeat(
empty,
(p) => p.team,
(preview) => this.renderTeamCard(preview, true),
)}
</div>
</div>
</div>
</div>`;
}
private renderFreeForAll() {
return html`${repeat(
this.clients,
(c) => c.clientID ?? c.username,
(client) =>
html`<span class="player-tag">
${client.username}
${client.clientID === this.lobbyCreatorClientID
? html`<span class="host-badge"
>(${translateText("host_modal.host_badge")})</span
>`
: html`<button
class="remove-player-btn"
@click=${() => this.onKickPlayer?.(client.clientID)}
aria-label=${translateText("host_modal.remove_player", {
username: client.username,
})}
>
×
</button>`}
</span>`,
)} `;
}
private renderTeamCard(preview: TeamPreviewData, isEmpty: boolean = false) {
return html`
<div class="bg-gray-800 border border-gray-700 rounded-xl flex flex-col">
<div
class="px-2 py-1 font-bold flex items-center justify-between text-white rounded-t-xl text-[13px] gap-2 bg-gray-700/70"
>
${this.showTeamColors
? html`<span
class="inline-block w-2.5 h-2.5 rounded-full border-2 border-white/90 shadow-inner"
style="background:${this.teamHeaderColor(preview.team)};"
></span>`
: null}
<span class="truncate">${preview.team}</span>
<span class="text-white/90"
>${preview.players.length}/${this.teamMaxSize}</span
>
</div>
<div class="p-2 ${isEmpty ? "" : "flex flex-col gap-1.5"}">
${isEmpty
? html`<div class="text-[11px] italic text-gray-400">
${translateText("host_modal.empty_team")}
</div>`
: repeat(
preview.players,
(p) => p.clientID ?? p.username,
(p) =>
html` <div
class="bg-gray-700/70 px-2 py-1 rounded text-xs flex items-center justify-between"
>
<span class="truncate">${p.username}</span>
${p.clientID === this.lobbyCreatorClientID
? html`<span class="ml-2 text-[11px] text-green-300"
>(${translateText("host_modal.host_badge")})</span
>`
: html`<button
class="remove-player-btn ml-2"
@click=${() => this.onKickPlayer?.(p.clientID)}
aria-label=${translateText(
"host_modal.remove_player",
{
username: p.username,
},
)}
>
×
</button>`}
</div>`,
)}
</div>
</div>
`;
}
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<Team, ClientInfo[]>();
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) ?? [],
}));
}
}
-3
View File
@@ -7,10 +7,7 @@ import { ColorAllocator } from "./ColorAllocator";
import { botColors, fallbackColors, humanColors, nationColors } from "./Colors";
import { Theme } from "./Config";
type ColorCache = Map<string, Colord>;
export class PastelTheme implements Theme {
private borderColorCache: ColorCache = new Map<string, Colord>();
private rand = new PseudoRandom(123);
private humanColorAllocator = new ColorAllocator(humanColors, fallbackColors);
private botColorAllocator = new ColorAllocator(botColors, botColors);