This commit is contained in:
Aotumuri
2026-01-25 16:45:47 +09:00
committed by Aotumuri
parent 6443fe790c
commit 3c9f66288b
5 changed files with 129 additions and 7 deletions
+6 -3
View File
@@ -332,12 +332,15 @@
"teams": "{num} teams",
"players_per_team": "of {num}",
"started": "Started",
"current": "Current public lobby",
"vote_button": "Vote on maps",
"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_login_required": "Log in to submit votes.",
"vote_submit": "Vote",
"vote_toast_submitted": "Vote submitted!"
},
"matchmaking_modal": {
"title": "1v1 Ranked Matchmaking (ALPHA)",
+7
View File
@@ -3,12 +3,14 @@ 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 {
@@ -23,6 +25,7 @@ export class PublicLobbySocket {
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(
@@ -34,6 +37,7 @@ export class PublicLobbySocket {
this.maxWsAttempts = options?.maxWsAttempts ?? 3;
this.pollIntervalMs = options?.pollIntervalMs ?? 1000;
this.onVoteRequest = options?.onVoteRequest;
this.onVoteStats = options?.onVoteStats;
}
start() {
@@ -87,6 +91,9 @@ export class PublicLobbySocket {
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);
+43
View File
@@ -59,6 +59,30 @@ export class MapVoteModal extends BaseModal {
);
}
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[],
@@ -166,6 +190,25 @@ export class MapVoteModal extends BaseModal {
</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>
`;
+36 -4
View File
@@ -29,6 +29,7 @@ export class PublicLobby extends LitElement {
@state() private mapImages: Map<GameID, string> = new Map();
@state() private joiningDotIndex: number = 0;
@state() private isLoggedIn: boolean = false;
@state() private activeVoteCount: number = 0;
private joiningInterval: number | null = null;
private currLobby: GameInfo | null = null;
@@ -40,6 +41,9 @@ export class PublicLobby extends LitElement {
onVoteRequest: () => {
void this.sendStoredVotes();
},
onVoteStats: (activeVoteCount) => {
this.activeVoteCount = activeVoteCount;
},
},
);
private handleUserMeResponse = (event: Event) => {
@@ -56,6 +60,11 @@ export class PublicLobby extends LitElement {
if (!customEvent.detail?.maps) return;
void this.handleMapVoteChange(customEvent);
};
private handleMapVoteSubmitEvent = (event: Event) => {
const customEvent = event as CustomEvent<{ maps: GameMapType[] }>;
if (!customEvent.detail?.maps) return;
void this.handleMapVoteSubmit(customEvent.detail.maps);
};
createRenderRoot() {
return this;
@@ -65,6 +74,7 @@ export class PublicLobby extends LitElement {
super.connectedCallback();
document.addEventListener("userMeResponse", this.handleUserMeResponse);
document.addEventListener("map-vote-change", this.handleMapVoteChangeEvent);
document.addEventListener("map-vote-submit", this.handleMapVoteSubmitEvent);
this.lobbySocket.start();
}
@@ -75,6 +85,10 @@ export class PublicLobby extends LitElement {
"map-vote-change",
this.handleMapVoteChangeEvent,
);
document.removeEventListener(
"map-vote-submit",
this.handleMapVoteSubmitEvent,
);
this.lobbySocket.stop();
this.stopJoiningAnimation();
}
@@ -95,6 +109,13 @@ export class PublicLobby extends LitElement {
this.lobbySocket.sendMapVote(auth.jwt, event.detail.maps);
}
private async handleMapVoteSubmit(maps: GameMapType[]) {
if (!this.isLoggedIn) return;
const auth = await userAuth();
if (!auth) return;
this.lobbySocket.sendMapVote(auth.jwt, maps);
}
private openMapVoteModal() {
const modal = document.querySelector("map-vote-modal") as
| (HTMLElement & { open: () => void })
@@ -177,10 +198,7 @@ export class PublicLobby extends LitElement {
return html`
<div class="flex flex-col gap-2">
<div
class="flex items-center gap-2 text-xs uppercase tracking-widest text-white/60 px-1"
>
<span>${translateText("public_lobby.current")}</span>
<div class="flex items-center gap-3 text-xs px-1">
<button
class="group flex items-center justify-center w-7 h-7 rounded-full border border-white/10 bg-slate-900/70 hover:bg-slate-800/90 text-white/80 hover:text-white transition-colors"
@click=${this.openMapVoteModal}
@@ -201,6 +219,20 @@ export class PublicLobby extends LitElement {
></path>
</svg>
</button>
<div class="flex flex-col gap-1">
<span class="text-xs uppercase tracking-widest text-white/70">
${translateText("public_lobby.vote_cta")}
</span>
<span
class="text-[10px] text-white/50 normal-case tracking-normal"
title=${translateText("public_lobby.vote_count_tooltip")}
aria-label=${translateText("public_lobby.vote_count_tooltip")}
>
${translateText("public_lobby.vote_count_label", {
count: this.activeVoteCount,
})}
</span>
</div>
</div>
<div class="relative">
+37
View File
@@ -131,6 +131,33 @@ function broadcastMapVoteRequest() {
});
}
function getActiveVoteCount(): number {
return mapVotesByUser.size;
}
function broadcastMapVoteStats() {
const message = JSON.stringify({
type: "map_vote_stats",
data: { activeVoteCount: getActiveVoteCount() },
});
const clientsToRemove: WebSocket[] = [];
connectedClients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
} else if (
client.readyState === WebSocket.CLOSED ||
client.readyState === WebSocket.CLOSING
) {
clientsToRemove.push(client);
}
});
clientsToRemove.forEach((client) => {
connectedClients.delete(client);
});
}
function normalizeVotedMaps(maps: GameMapType[]): Set<GameMapType> {
const unique = new Set<GameMapType>();
maps.forEach((map) => {
@@ -165,6 +192,7 @@ function unregisterVoteConnection(ws: WebSocket) {
if (connections.size === 0) {
mapVoteConnectionsByUser.delete(userId);
mapVotesByUser.delete(userId);
broadcastMapVoteStats();
} else {
mapVoteConnectionsByUser.set(userId, connections);
}
@@ -173,9 +201,11 @@ function unregisterVoteConnection(ws: WebSocket) {
function setUserVote(userId: string, maps: Set<GameMapType>) {
if (maps.size === 0) {
mapVotesByUser.delete(userId);
broadcastMapVoteStats();
return;
}
mapVotesByUser.set(userId, maps);
broadcastMapVoteStats();
}
function collectMapVoteWeights(): Map<GameMapType, number> {
@@ -190,6 +220,7 @@ function collectMapVoteWeights(): Map<GameMapType, number> {
function clearMapVotes() {
mapVotesByUser.clear();
broadcastMapVoteStats();
}
async function handleLobbyMessage(ws: WebSocket, raw: WebSocket.RawData) {
@@ -239,6 +270,12 @@ export async function startMaster() {
ws.send(
JSON.stringify({ type: "lobbies_update", data: publicLobbiesData }),
);
ws.send(
JSON.stringify({
type: "map_vote_stats",
data: { activeVoteCount: getActiveVoteCount() },
}),
);
ws.on("close", () => {
connectedClients.delete(ws);