mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 20:06:46 +00:00
5562993042
## Description: > I always lose track of myself in lobbies, could our names be bolded or even our entire team bolded for ease of readability? This PR highlights own username and own team in lobby modal, to make it easy for players to track themselves. It's also useful when your username gets changed by the profanity filter. Based on [this Discord suggestion](https://discord.com/channels/1284581928254701718/1484056268355145778) ### Screenshots FFA (Public): <img width="916" height="805" alt="image" src="https://github.com/user-attachments/assets/5d769b48-3217-495c-bdcd-9a61fd05cd8f" /> Teams (Public): <img width="912" height="798" alt="image" src="https://github.com/user-attachments/assets/0b3966f9-4094-4dfe-a11f-ce225824690a" /> FFA (Private): <img width="912" height="798" alt="image" src="https://github.com/user-attachments/assets/21ac32ac-1fd0-4318-b2c8-44f3a2756e2e" /> <img width="906" height="800" alt="image" src="https://github.com/user-attachments/assets/7530c4a2-3bc7-4b24-828d-766d65b85f89" /> Teams (Private): <img width="907" height="799" alt="image" src="https://github.com/user-attachments/assets/c8773fb8-5015-4247-8fde-4628f0850660" /> <img width="910" height="800" alt="image" src="https://github.com/user-attachments/assets/15c56a54-ffea-45ef-a86f-1a8be6995e1d" /> ## 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: deshack_82603
405 lines
13 KiB
TypeScript
405 lines
13 KiB
TypeScript
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 { assignTeamsLobbyPreview } from "../../core/game/TeamAssignment";
|
|
import { UserSettings } from "../../core/game/UserSettings";
|
|
import { ClientInfo, TeamCountConfig } from "../../core/Schemas";
|
|
import { createRandomName, formatPlayerDisplayName } from "../../core/Util";
|
|
import { getTranslatedPlayerTeamLabel, translateText } from "../Utils";
|
|
|
|
export interface TeamPreviewData {
|
|
team: Team;
|
|
players: ClientInfo[];
|
|
}
|
|
|
|
@customElement("lobby-player-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({ type: String }) currentClientID: string = "";
|
|
@property({ attribute: "team-count" }) teamCount: TeamCountConfig = 2;
|
|
@property({ type: Function }) onKickPlayer?: (clientID: string) => void;
|
|
@property({ type: Number }) nationCount: number = 0;
|
|
@property({ type: Boolean }) isPublicGame: boolean = false;
|
|
|
|
private theme: PastelTheme = new PastelTheme();
|
|
@state() private showTeamColors: boolean = false;
|
|
private userSettings: UserSettings = new UserSettings();
|
|
|
|
/**
|
|
* For public HumansVsNations games, nation count always matches human count
|
|
* (server enforces this in NationCreation). For private games, the host
|
|
* controls the nation count via the slider.
|
|
*/
|
|
private get effectiveNationCount(): number {
|
|
if (this.isPublicGame && this.teamCount === HumansVsNations) {
|
|
return this.clients.length;
|
|
}
|
|
return this.nationCount;
|
|
}
|
|
|
|
willUpdate(changedProperties: Map<string, any>) {
|
|
// Recompute team preview when relevant properties change
|
|
// clients is updated from WebSocket lobby_info events
|
|
if (
|
|
changedProperties.has("gameMode") ||
|
|
changedProperties.has("clients") ||
|
|
changedProperties.has("teamCount") ||
|
|
changedProperties.has("nationCount") ||
|
|
changedProperties.has("isPublicGame")
|
|
) {
|
|
const teamsList = this.getTeamList();
|
|
this.computeTeamPreview(teamsList);
|
|
this.showTeamColors = teamsList.length <= 7;
|
|
}
|
|
}
|
|
|
|
render() {
|
|
return html`
|
|
<div class="border-t border-white/10 pt-6">
|
|
<div class="flex justify-between items-center mb-4">
|
|
<div
|
|
class="text-xs font-bold text-white/40 uppercase tracking-widest"
|
|
>
|
|
${this.clients.length}
|
|
${this.clients.length === 1
|
|
? translateText("host_modal.player")
|
|
: translateText("host_modal.players")}
|
|
<span style="margin: 0 8px;">•</span>
|
|
${this.effectiveNationCount}
|
|
${this.effectiveNationCount === 1
|
|
? translateText("host_modal.nation_player")
|
|
: translateText("host_modal.nation_players")}
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="players-list block rounded-lg border border-white/10 bg-white/5 p-2"
|
|
>
|
|
${this.gameMode === GameMode.Team
|
|
? this.renderTeamMode()
|
|
: this.renderFreeForAll()}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
createRenderRoot() {
|
|
return this;
|
|
}
|
|
|
|
private renderTeamMode() {
|
|
const active = this.teamPreview.filter(
|
|
(t) => t.players.length > 0 || t.team === ColoredTeams.Nations,
|
|
);
|
|
const empty = this.teamPreview.filter(
|
|
(t) => t.players.length === 0 && t.team !== ColoredTeams.Nations,
|
|
);
|
|
return html` <div
|
|
class="flex flex-col md:flex-row gap-3 md:gap-4 items-stretch"
|
|
>
|
|
<div
|
|
class="w-full md:w-60 bg-gray-800 p-2 border border-gray-700 rounded-lg"
|
|
>
|
|
<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) => {
|
|
const displayName = this.getClientDisplayName(client);
|
|
return html`<div
|
|
class="px-2 py-1 rounded-sm mb-1 text-xs text-white border
|
|
${this.isCurrentPlayer(client)
|
|
? "bg-sky-600/20 border-sky-500/40"
|
|
: "bg-gray-700/70 border-transparent"}"
|
|
>
|
|
${displayName}
|
|
</div>`;
|
|
},
|
|
)}
|
|
</div>
|
|
<div class="flex-1 flex flex-col gap-3 md:gap-4 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>
|
|
${empty.length > 0
|
|
? html`<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) => {
|
|
const displayName = this.getClientDisplayName(client);
|
|
return html`<span
|
|
class="player-tag ${this.isCurrentPlayer(client)
|
|
? "current-player"
|
|
: ""}"
|
|
>
|
|
<span class="text-white">${displayName}</span>
|
|
${client.clientID === this.lobbyCreatorClientID
|
|
? html`<span class="host-badge"
|
|
>(${translateText("host_modal.host_badge")})</span
|
|
>`
|
|
: this.onKickPlayer
|
|
? html`<button
|
|
class="remove-player-btn"
|
|
@click=${() => this.onKickPlayer?.(client.clientID)}
|
|
aria-label=${translateText("host_modal.remove_player", {
|
|
username: displayName,
|
|
})}
|
|
>
|
|
×
|
|
</button>`
|
|
: html``}
|
|
</span>`;
|
|
},
|
|
)} `;
|
|
}
|
|
|
|
private renderTeamCard(preview: TeamPreviewData, isEmpty: boolean = false) {
|
|
const displayCount =
|
|
preview.team === ColoredTeams.Nations
|
|
? this.effectiveNationCount
|
|
: preview.players.length;
|
|
|
|
const maxTeamSize =
|
|
preview.team === ColoredTeams.Nations
|
|
? this.effectiveNationCount
|
|
: this.teamMaxSize;
|
|
|
|
const teamLabel = getTranslatedPlayerTeamLabel(preview.team);
|
|
|
|
return html`
|
|
<div
|
|
class="bg-gray-800 border rounded-xl flex flex-col
|
|
${this.teamContainsCurrentPlayer(preview)
|
|
? "border-sky-500/60"
|
|
: "border-gray-700"}"
|
|
>
|
|
<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 bg-(--bg)"
|
|
style="--bg:${this.teamHeaderColor(preview.team)};"
|
|
></span>`
|
|
: null}
|
|
<span class="truncate">${teamLabel}</span>
|
|
<span class="text-white/90">${displayCount}/${maxTeamSize}</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) => {
|
|
const displayName = this.getClientDisplayName(p);
|
|
return html` <div
|
|
class="px-2 py-1 rounded-sm text-xs flex items-center justify-between border
|
|
${this.isCurrentPlayer(p)
|
|
? "bg-sky-600/20 border-sky-500/40"
|
|
: "bg-gray-700/70 border-transparent"}"
|
|
>
|
|
<span class="truncate text-white">${displayName}</span>
|
|
${p.clientID === this.lobbyCreatorClientID
|
|
? html`<span class="ml-2 text-[11px] text-green-300"
|
|
>(${translateText("host_modal.host_badge")})</span
|
|
>`
|
|
: this.onKickPlayer
|
|
? html`<button
|
|
class="remove-player-btn ml-2"
|
|
@click=${() => this.onKickPlayer?.(p.clientID)}
|
|
aria-label=${translateText(
|
|
"host_modal.remove_player",
|
|
{
|
|
username: displayName,
|
|
},
|
|
)}
|
|
>
|
|
×
|
|
</button>`
|
|
: html``}
|
|
</div>`;
|
|
},
|
|
)}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private getTeamList(): Team[] {
|
|
if (this.gameMode !== GameMode.Team) return [];
|
|
const playerCount = this.clients.length + this.effectiveNationCount;
|
|
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(teams: Team[] = []) {
|
|
if (this.gameMode !== GameMode.Team) {
|
|
this.teamPreview = [];
|
|
this.teamMaxSize = 0;
|
|
return;
|
|
}
|
|
|
|
// 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,
|
|
false,
|
|
c.clanTag,
|
|
),
|
|
);
|
|
const assignment = assignTeamsLobbyPreview(
|
|
players,
|
|
teams,
|
|
this.effectiveNationCount,
|
|
);
|
|
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);
|
|
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 + this.effectiveNationCount) / teams.length,
|
|
),
|
|
);
|
|
}
|
|
this.teamPreview = teams.map((t) => ({
|
|
team: t,
|
|
players: buckets.get(t) ?? [],
|
|
}));
|
|
}
|
|
|
|
private isCurrentPlayer(client: ClientInfo): boolean {
|
|
return !!this.currentClientID && client.clientID === this.currentClientID;
|
|
}
|
|
|
|
private teamContainsCurrentPlayer(preview: TeamPreviewData): boolean {
|
|
return preview.players.some((p) => this.isCurrentPlayer(p));
|
|
}
|
|
|
|
private getClientDisplayName(client: ClientInfo): string {
|
|
const full = formatPlayerDisplayName(client.username, client.clanTag);
|
|
if (!this.userSettings.anonymousNames()) {
|
|
return full;
|
|
}
|
|
if (this.currentClientID && client.clientID === this.currentClientID) {
|
|
return full;
|
|
}
|
|
// Keep clan tag visible while anonymizing only the username.
|
|
const anonymizedUsername =
|
|
createRandomName(client.username, PlayerType.Human) ?? client.username;
|
|
return formatPlayerDisplayName(anonymizedUsername, client.clanTag);
|
|
}
|
|
}
|