mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-25 20:44:40 +00:00
2c8a66625c
**Add approved & assigned issue number here:** Resolves #2549 ## Description: Themes are purely for the client's rendering, and the server doesn't need context on them. This PR moves `Theme.ts` from `src/core/configuration` to `src/client/theme` and moves affiliation colors to `render-settings.json`. This is to support the ability to add additional themes more quickly, such as colorblind-friendly themes. No visible changes occur from this refactor. ## 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 ## Please put your Discord username so you can be contacted if a bug or regression is found: jetaviz --------- Co-authored-by: Josh Harris <josh@wickedsick.com>
409 lines
13 KiB
TypeScript
409 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 {
|
|
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 { Theme } from "../theme/Theme";
|
|
import { themeProvider } from "../theme/ThemeProvider";
|
|
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 get theme(): Theme {
|
|
return themeProvider.current();
|
|
}
|
|
@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-malibu-blue/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-malibu-blue/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,
|
|
c.friends ?? [],
|
|
),
|
|
);
|
|
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);
|
|
}
|
|
}
|