Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@
<alert-frame></alert-frame>
<chat-modal></chat-modal>
<multi-tab-modal></multi-tab-modal>
<map-vote-modal></map-vote-modal>
<game-left-sidebar></game-left-sidebar>
<performance-overlay></performance-overlay>
<player-info-overlay></player-info-overlay>
Expand Down
11 changes: 10 additions & 1 deletion resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,16 @@
"teams_hvn_detailed": "{num} Humans vs {num} Nations",
"teams": "{num} teams",
"players_per_team": "of {num}",
"started": "Started"
"started": "Started",
"vote_cta": "Vote for next map!",
"vote_count_label": "{count, plural, one {# vote influences the next map} other {# votes influence the next map}}",
"vote_count_tooltip": "Number of players currently voting (connected & waiting in public lobby)",
"vote_title": "Map Voting",
"vote_description": "Select any number of maps to influence the next public lobby.",
"vote_saved": "Your selection is saved on this device.",
"vote_login_required": "Log in to submit votes.",
"vote_submit": "Vote",
"vote_toast_submitted": "Vote submitted!"
},
"matchmaking_modal": {
"title": "1v1 Ranked Matchmaking (ALPHA)",
Expand Down
43 changes: 42 additions & 1 deletion src/client/LobbySocket.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { GameMapType } from "../core/game/Game";
import { GameInfo } from "../core/Schemas";

type LobbyUpdateHandler = (lobbies: GameInfo[]) => void;
type VoteRequestHandler = () => void;
type VoteStatsHandler = (activeVoteCount: number) => void;

interface LobbySocketOptions {
reconnectDelay?: number;
maxWsAttempts?: number;
pollIntervalMs?: number;
onVoteRequest?: VoteRequestHandler;
onVoteStats?: VoteStatsHandler;
}

export class PublicLobbySocket {
Expand All @@ -19,6 +24,9 @@ export class PublicLobbySocket {
private readonly maxWsAttempts: number;
private readonly pollIntervalMs: number;
private readonly onLobbiesUpdate: LobbyUpdateHandler;
private readonly onVoteRequest?: VoteRequestHandler;
private readonly onVoteStats?: VoteStatsHandler;
private pendingVote: { token: string; maps: GameMapType[] } | null = null;

constructor(
onLobbiesUpdate: LobbyUpdateHandler,
Expand All @@ -28,6 +36,8 @@ export class PublicLobbySocket {
this.reconnectDelay = options?.reconnectDelay ?? 3000;
this.maxWsAttempts = options?.maxWsAttempts ?? 3;
this.pollIntervalMs = options?.pollIntervalMs ?? 1000;
this.onVoteRequest = options?.onVoteRequest;
this.onVoteStats = options?.onVoteStats;
}

start() {
Expand Down Expand Up @@ -71,13 +81,19 @@ export class PublicLobbySocket {
this.wsReconnectTimeout = null;
}
this.stopFallbackPolling();
this.flushPendingVote();
}

private handleMessage(event: MessageEvent) {
try {
const message = JSON.parse(event.data as string);
if (message.type === "lobbies_update") {
this.onLobbiesUpdate(message.data?.lobbies ?? []);
} else if (message.type === "map_vote_request") {
this.onVoteRequest?.();
} else if (message.type === "map_vote_stats") {
const activeVoteCount = Number(message.data?.activeVoteCount ?? 0);
this.onVoteStats?.(activeVoteCount);
}
} catch (error) {
console.error("Error parsing WebSocket message:", error);
Expand Down Expand Up @@ -114,7 +130,7 @@ export class PublicLobbySocket {
console.error("WebSocket error:", error);
}

private handleConnectError(error: unknown) {
private handleConnectError(error: Error | Event | string) {
console.error("Error connecting WebSocket:", error);
if (!this.wsAttemptCounted) {
this.wsAttemptCounted = true;
Expand Down Expand Up @@ -162,6 +178,31 @@ export class PublicLobbySocket {
}
}

public sendMapVote(token: string, maps: GameMapType[]) {
this.pendingVote = { token, maps };
this.flushPendingVote();
}

public clearMapVote() {
this.pendingVote = null;
}

private flushPendingVote() {
if (!this.pendingVote) return;
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
try {
this.ws.send(
JSON.stringify({
type: "map_vote",
token: this.pendingVote.token,
maps: this.pendingVote.maps,
}),
);
} catch (error) {
console.error("Failed to send map vote:", error);
}
}

private async fetchLobbiesHTTP() {
try {
const response = await fetch(`/api/public_lobbies`);
Expand Down
9 changes: 9 additions & 0 deletions src/client/Main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { JoinPrivateLobbyModal } from "./JoinPrivateLobbyModal";
import "./LangSelector";
import { LangSelector } from "./LangSelector";
import { initLayout } from "./Layout";
import { MapVoteModal } from "./MapVoteModal";
import "./Matchmaking";
import { MatchmakingModal } from "./Matchmaking";
import { initNavigation } from "./Navigation";
Expand Down Expand Up @@ -222,6 +223,7 @@ class Client {
private patternsModal: TerritoryPatternsModal;
private tokenLoginModal: TokenLoginModal;
private matchmakingModal: MatchmakingModal;
private mapVoteModal: MapVoteModal | null = null;

private gutterAds: GutterAds;

Expand Down Expand Up @@ -274,6 +276,13 @@ class Client {
console.warn("Username input element not found");
}

this.mapVoteModal = document.querySelector(
"map-vote-modal",
) as MapVoteModal;
if (!this.mapVoteModal) {
console.warn("Map vote modal element not found");
}

this.publicLobby = document.querySelector("public-lobby") as PublicLobby;

window.addEventListener("beforeunload", async () => {
Expand Down
230 changes: 230 additions & 0 deletions src/client/MapVoteModal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { TemplateResult, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import type { UserMeResponse } from "../core/ApiSchemas";
import { GameMapType, mapCategories } from "../core/game/Game";
import { publicLobbyMaps } from "../core/game/PublicLobbyMaps";
import { hasLinkedAccount } from "./Api";
import "./components/baseComponents/Modal";
import { BaseModal } from "./components/BaseModal";
import "./components/Maps";
import { modalHeader } from "./components/ui/ModalHeader";
import { loadStoredMapVotes, saveStoredMapVotes } from "./MapVoteStorage";

@customElement("map-vote-modal")
export class MapVoteModal extends BaseModal {
@property({ type: Boolean }) loggedIn = false;
@state() private selectedMaps = new Set<GameMapType>();

private readonly availableMaps = new Set(publicLobbyMaps);
private handleUserMeResponse = (event: Event) => {
const customEvent = event as CustomEvent<UserMeResponse | false>;
this.loggedIn = hasLinkedAccount(customEvent.detail);
};

constructor() {
super();
this.id = "map-vote-modal";
}

connectedCallback() {
super.connectedCallback();
document.addEventListener("userMeResponse", this.handleUserMeResponse);
}

disconnectedCallback() {
document.removeEventListener("userMeResponse", this.handleUserMeResponse);
super.disconnectedCallback();
}

protected onOpen(): void {
this.selectedMaps = new Set(loadStoredMapVotes());
}

private toggleMapSelection(map: GameMapType) {
const next = new Set(this.selectedMaps);
if (next.has(map)) {
next.delete(map);
} else {
next.add(map);
}
this.selectedMaps = next;
saveStoredMapVotes(Array.from(next));
this.dispatchEvent(
new CustomEvent("map-vote-change", {
detail: { maps: Array.from(next) },
bubbles: true,
composed: true,
}),
);
}

private handleVoteSubmit = () => {
const maps = Array.from(this.selectedMaps);
saveStoredMapVotes(maps);
this.dispatchEvent(
new CustomEvent("map-vote-submit", {
detail: { maps },
bubbles: true,
composed: true,
}),
);
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message: this.loggedIn
? translateText("public_lobby.vote_toast_submitted")
: translateText("public_lobby.vote_saved"),
color: "green",
duration: 2500,
},
}),
);
this.close();
};

private renderCategory(
categoryKey: string,
maps: GameMapType[],
): TemplateResult {
if (maps.length === 0) return html``;
return html`
<div class="w-full">
<h4
class="text-xs font-bold text-white/40 uppercase tracking-widest mb-4 pl-2"
>
${translateText(`map_categories.${categoryKey}`)}
</h4>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
${maps.map((mapValue) => {
const mapKey = Object.entries(GameMapType).find(
([, v]) => v === mapValue,
)?.[0];
return html`
<div
@click=${() => this.toggleMapSelection(mapValue)}
class="cursor-pointer transition-transform duration-200 active:scale-95"
>
<map-display
.mapKey=${mapKey}
.selected=${this.selectedMaps.has(mapValue)}
.translation=${translateText(`map.${mapKey?.toLowerCase()}`)}
></map-display>
</div>
`;
})}
</div>
</div>
`;
}

render() {
const categoryEntries = Object.entries(mapCategories) as Array<
[string, GameMapType[]]
>;
const categories: Array<[string, GameMapType[]]> = categoryEntries
.map(
([categoryKey, maps]) =>
[categoryKey, maps.filter((map) => this.availableMaps.has(map))] as [
string,
GameMapType[],
],
)
.filter(([, maps]) => maps.length > 0);
const loginBanner = this.loggedIn
? undefined
: html`<div
class="px-3 py-2 text-xs font-bold uppercase tracking-wider transition-colors duration-200 rounded-lg bg-yellow-500/20 text-yellow-400 border border-yellow-500/30 whitespace-nowrap shrink-0"
>
${translateText("public_lobby.vote_login_required")}
</div>`;

const content = html`
<div class="h-full flex flex-col overflow-hidden select-none">
${modalHeader({
title: translateText("public_lobby.vote_title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
rightContent: loginBanner,
})}

<div class="flex-1 overflow-y-auto custom-scrollbar px-6 pb-6 mr-1">
<div class="max-w-5xl mx-auto space-y-6 pt-4">
<div class="space-y-6">
<div
class="flex items-center gap-4 pb-2 border-b border-white/10"
>
<div
class="w-8 h-8 rounded-lg bg-blue-500/20 flex items-center justify-center text-blue-400"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M21.731 2.269a2.625 2.625 0 00-3.712 0l-1.157 1.157 3.712 3.712 1.157-1.157a2.625 2.625 0 000-3.712zM19.513 8.199l-3.712-3.712-12.15 12.15a5.25 5.25 0 00-1.32 2.214l-.8 2.685a.75.75 0 00.933.933l2.685-.8a5.25 5.25 0 002.214-1.32L19.513 8.2z"
/>
</svg>
</div>
<h3
class="text-lg font-bold text-white uppercase tracking-wider"
>
${translateText("map.map")}
</h3>
</div>

<div class="space-y-2 text-sm text-white/70">
<p>${translateText("public_lobby.vote_description")}</p>
<p class="text-white/50">
${translateText("public_lobby.vote_saved")}
</p>
</div>

<div class="space-y-8">
${categories.map(([categoryKey, maps]) =>
this.renderCategory(categoryKey, maps),
)}
</div>
</div>
</div>
</div>

<div
class="flex items-center justify-end gap-3 px-6 py-4 border-t border-white/10"
>
<button
class="px-4 py-2 text-xs font-bold uppercase tracking-widest rounded-lg bg-white/10 text-white/70 hover:text-white hover:bg-white/20 transition-colors"
type="button"
@click=${() => this.close()}
>
${translateText("common.cancel")}
</button>
<button
class="px-5 py-2 text-xs font-bold uppercase tracking-widest rounded-lg bg-blue-600 text-white hover:bg-blue-500 transition-colors"
type="button"
@click=${this.handleVoteSubmit}
>
${translateText("public_lobby.vote_submit")}
</button>
</div>
</div>
`;

if (this.inline) {
return content;
}

return html`
<o-modal
title=""
?hideCloseButton=${true}
?inline=${this.inline}
hideHeader
>
${content}
</o-modal>
`;
}
}
Loading
Loading