mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 12:00:44 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) ?? [],
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user