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) { // 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`
${this.clients.length} ${this.clients.length === 1 ? translateText("host_modal.player") : translateText("host_modal.players")} ${this.effectiveNationCount} ${this.effectiveNationCount === 1 ? translateText("host_modal.nation_player") : translateText("host_modal.nation_players")}
${this.gameMode === GameMode.Team ? this.renderTeamMode() : this.renderFreeForAll()}
`; } 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`
${translateText("host_modal.players")}
${repeat( this.clients, (c) => c.clientID ?? c.username, (client) => { const displayName = this.getClientDisplayName(client); return html`
${displayName}
`; }, )}
${translateText("host_modal.assigned_teams")}
${repeat( active, (p) => p.team, (preview) => this.renderTeamCard(preview, false), )}
${empty.length > 0 ? html`
${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) => { const displayName = this.getClientDisplayName(client); return html` ${displayName} ${client.clientID === this.lobbyCreatorClientID ? html`(${translateText("host_modal.host_badge")})` : this.onKickPlayer ? html`` : html``} `; }, )} `; } 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`
${this.showTeamColors ? html` ` : null} ${teamLabel} ${displayCount}/${maxTeamSize}
${isEmpty ? html`
${translateText("host_modal.empty_team")}
` : repeat( preview.players, (p) => p.clientID ?? p.username, (p) => { const displayName = this.getClientDisplayName(p); return html`
${displayName} ${p.clientID === this.lobbyCreatorClientID ? html`(${translateText("host_modal.host_badge")})` : this.onKickPlayer ? html`` : html``}
`; }, )}
`; } 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(); 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); } }