feat: new duel implementation

This commit is contained in:
Trajkov Dimitar
2025-12-12 15:23:07 +01:00
parent 79710467b0
commit 7a6f84ac95
7 changed files with 288 additions and 118 deletions
+7 -3
View File
@@ -284,9 +284,13 @@
<div>
<matchmaking-button class="w-[20%] md:w-[15%]"></matchmaking-button>
</div>
<div>
<ranked-queue class="block"></ranked-queue>
</div>
<ranked-queue></ranked-queue>
<o-button
id="quick-match-button"
title="Quick Match"
translationKey="ranked_queue.quick_match"
block
></o-button>
<div class="container__row container__row--equal">
<o-button
id="host-lobby-button"
+8 -1
View File
@@ -305,7 +305,14 @@
"elo": "ELO",
"games": "games",
"wins_short": "W",
"losses_short": "L"
"losses_short": "L",
"ffa": "FFA",
"duel": "1v1",
"duos": "Duos",
"trios": "Trios",
"quads": "Quads",
"ranked_matchmaking": "Ranked Matchmaking",
"quick_match": "Lobby"
},
"username": {
"enter_username": "Enter your username",
+13
View File
@@ -33,6 +33,7 @@ import "./NewsModal";
import "./PublicLobby";
import { PublicLobby } from "./PublicLobby";
import "./RankedQueue";
import { RankedQueue } from "./RankedQueue";
import { SinglePlayerModal } from "./SinglePlayerModal";
import "./StatsModal";
import { TerritoryPatternsModal } from "./TerritoryPatternsModal";
@@ -354,6 +355,18 @@ class Client {
}
});
const rankedQueueModal = document.querySelector(
"ranked-queue",
) as RankedQueue;
const quickMatchButton = document.getElementById("quick-match-button");
if (quickMatchButton === null)
throw new Error("Missing quick-match-button");
quickMatchButton.addEventListener("click", () => {
if (this.usernameInput?.isValid()) {
rankedQueueModal?.open();
}
});
if (this.userSettings.darkMode()) {
document.documentElement.classList.add("dark");
} else {
+226 -105
View File
@@ -1,5 +1,6 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { customElement, query, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { generateID } from "../core/Util";
import { getApiBase, getUserMe } from "./Api";
import { userAuth } from "./Auth";
@@ -7,7 +8,7 @@ import { JoinLobbyEvent } from "./Main";
import { translateText } from "./Utils";
type QueueType = "ranked" | "unranked";
type GameMode = "ffa" | "team" | "duel";
type GameMode = "ffa" | "team" | "duel" | "duos" | "trios" | "quads";
interface QueueStatus {
queueSize: number;
@@ -26,6 +27,11 @@ interface LeaderboardEntry {
@customElement("ranked-queue")
export class RankedQueue extends LitElement {
@query("o-modal") private modalEl!: HTMLElement & {
open: () => void;
close: () => void;
};
@state() private inQueue: boolean = false;
@state() private queueType: QueueType = "ranked";
@state() private gameMode: GameMode = "ffa";
@@ -53,17 +59,25 @@ export class RankedQueue extends LitElement {
/**
* Get the current player's ELO for the selected game mode
* Returns null for unranked modes (duos, trios, quads, unranked ffa)
*/
private get currentPlayerElo(): number | null {
if (this.queueType === "unranked") {
return null; // No ELO for unranked modes
}
return this.gameMode === "duel"
? this.playerEloByMode.duel
: this.playerEloByMode.ffa;
}
async connectedCallback() {
super.connectedCallback();
// Fetch player ELO and leaderboard immediately when component loads
await Promise.all([this.fetchPlayerElo(), this.fetchLeaderboard()]);
/**
* Check if the current mode is a ranked mode (has ELO tracking)
*/
private get isRankedMode(): boolean {
return (
this.queueType === "ranked" &&
(this.gameMode === "ffa" || this.gameMode === "duel")
);
}
disconnectedCallback() {
@@ -125,7 +139,6 @@ export class RankedQueue extends LitElement {
}
} catch (error) {
console.error("Failed to fetch leaderboard:", error);
// Don't show error to user, just silently fail
} finally {
this.isLoadingLeaderboard = false;
}
@@ -160,7 +173,6 @@ export class RankedQueue extends LitElement {
const token = loginResult.jwt;
// Determine WebSocket URL based on environment
// In development, use local matchmaking server; in production, use API
const matchmakingBase = process?.env?.MATCHMAKING_WS_URL;
const wsUrl = matchmakingBase
? `${matchmakingBase}/matchmaking/join`
@@ -240,7 +252,6 @@ export class RankedQueue extends LitElement {
case "auth_success":
console.log("Authentication successful");
if (message.playerElo !== undefined) {
// Update the ELO for the current game mode from the server response
if (this.gameMode === "duel") {
this.playerEloByMode = {
...this.playerEloByMode,
@@ -306,6 +317,7 @@ export class RankedQueue extends LitElement {
this.inQueue = false;
this.queueStatus = null;
this.cleanup();
this.close();
}
private async joinQueue() {
@@ -313,10 +325,8 @@ export class RankedQueue extends LitElement {
return;
}
// Connect WebSocket if not connected
await this.connectWebSocket();
// Function to send join queue message
const sendJoinMessage = () => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(
@@ -329,12 +339,10 @@ export class RankedQueue extends LitElement {
}
};
// Send join queue message immediately if connected, or wait for connection
if (this.ws) {
if (this.ws.readyState === WebSocket.OPEN) {
sendJoinMessage();
} else if (this.ws.readyState === WebSocket.CONNECTING) {
// Wait for connection to open before sending join message
this.ws.addEventListener("open", () => sendJoinMessage(), {
once: true,
});
@@ -347,7 +355,6 @@ export class RankedQueue extends LitElement {
return;
}
// Send leave queue message
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(
JSON.stringify({
@@ -362,123 +369,243 @@ export class RankedQueue extends LitElement {
}
private setQueueType(type: QueueType) {
if (this.inQueue) {
return; // Can't change while in queue
if (this.inQueue || this.isConnecting) {
return;
}
if (this.queueType !== type) {
this.queueType = type;
this.gameMode = "ffa";
if (type === "ranked") {
this.fetchLeaderboard();
}
}
this.queueType = type;
}
private setGameMode(mode: GameMode) {
if (this.inQueue) {
return; // Can't change while in queue
if (this.inQueue || this.isConnecting) {
return;
}
if (this.gameMode !== mode) {
this.gameMode = mode;
// Refresh leaderboard for the new mode
this.fetchLeaderboard();
if (this.queueType === "ranked") {
this.fetchLeaderboard();
}
}
}
public async open() {
this.modalEl?.open();
await Promise.all([this.fetchPlayerElo(), this.fetchLeaderboard()]);
}
public close() {
if (this.inQueue) {
this.leaveQueue();
}
this.modalEl?.close();
}
render() {
return html`
<div class="bg-gray-900 border border-blue-500/50 rounded-2xl p-4">
<!-- Header -->
<div class="text-center mb-3">
<h3 class="text-white font-bold text-lg">
${translateText("ranked_queue.ranked_matchmaking")}
</h3>
</div>
<div class="flex flex-col gap-3">
<!-- Game Mode Toggle -->
<o-modal
id="ranked-queue-modal"
title="${translateText("ranked_queue.quick_match")}"
>
<div class="flex flex-col gap-4">
<!-- Ranked/Unranked Toggle -->
<div class="flex gap-2">
<button
@click=${() => this.setGameMode("ffa")}
?disabled=${this.inQueue}
class="flex-1 py-2 rounded-lg font-medium text-sm transition-colors ${this
.gameMode === "ffa"
? "bg-blue-600 text-white"
: "bg-gray-700 text-gray-300 hover:bg-gray-600"} ${this.inQueue
? "opacity-50 cursor-not-allowed"
: ""}"
@click=${() => this.setQueueType("ranked")}
?disabled=${this.inQueue || this.isConnecting}
class=${classMap({
"c-button": true,
"c-button--block": true,
"c-button--secondary": this.queueType !== "ranked",
"c-button--disabled": this.inQueue || this.isConnecting,
})}
>
${translateText("ranked_queue.ffa")}
${translateText("ranked_queue.ranked")}
</button>
<button
@click=${() => this.setGameMode("duel")}
?disabled=${this.inQueue}
class="flex-1 py-2 rounded-lg font-medium text-sm transition-colors ${this
.gameMode === "duel"
? "bg-blue-600 text-white"
: "bg-gray-700 text-gray-300 hover:bg-gray-600"} ${this.inQueue
? "opacity-50 cursor-not-allowed"
: ""}"
@click=${() => this.setQueueType("unranked")}
?disabled=${this.inQueue || this.isConnecting}
class=${classMap({
"c-button": true,
"c-button--block": true,
"c-button--secondary": this.queueType !== "unranked",
"c-button--disabled": this.inQueue || this.isConnecting,
})}
>
${translateText("ranked_queue.duel")}
${translateText("ranked_queue.unranked")}
</button>
</div>
<!-- Game Mode Toggle -->
${this.queueType === "ranked"
? html`
<div class="flex gap-2">
<button
@click=${() => this.setGameMode("ffa")}
?disabled=${this.inQueue || this.isConnecting}
class=${classMap({
"c-button": true,
"c-button--block": true,
"c-button--secondary": this.gameMode !== "ffa",
"c-button--disabled": this.inQueue || this.isConnecting,
})}
>
${translateText("ranked_queue.ffa")}
</button>
<button
@click=${() => this.setGameMode("duel")}
?disabled=${this.inQueue || this.isConnecting}
class=${classMap({
"c-button": true,
"c-button--block": true,
"c-button--secondary": this.gameMode !== "duel",
"c-button--disabled": this.inQueue || this.isConnecting,
})}
>
${translateText("ranked_queue.duel")}
</button>
</div>
`
: html`
<div class="flex gap-2 flex-wrap">
<button
@click=${() => this.setGameMode("ffa")}
?disabled=${this.inQueue || this.isConnecting}
class=${classMap({
"c-button": true,
"flex-1": true,
"c-button--secondary": this.gameMode !== "ffa",
"c-button--disabled": this.inQueue || this.isConnecting,
})}
>
${translateText("ranked_queue.ffa")}
</button>
<button
@click=${() => this.setGameMode("duos")}
?disabled=${this.inQueue || this.isConnecting}
class=${classMap({
"c-button": true,
"flex-1": true,
"c-button--secondary": this.gameMode !== "duos",
"c-button--disabled": this.inQueue || this.isConnecting,
})}
>
${translateText("ranked_queue.duos")}
</button>
<button
@click=${() => this.setGameMode("trios")}
?disabled=${this.inQueue || this.isConnecting}
class=${classMap({
"c-button": true,
"flex-1": true,
"c-button--secondary": this.gameMode !== "trios",
"c-button--disabled": this.inQueue || this.isConnecting,
})}
>
${translateText("ranked_queue.trios")}
</button>
<button
@click=${() => this.setGameMode("quads")}
?disabled=${this.inQueue || this.isConnecting}
class=${classMap({
"c-button": true,
"flex-1": true,
"c-button--secondary": this.gameMode !== "quads",
"c-button--disabled": this.inQueue || this.isConnecting,
})}
>
${translateText("ranked_queue.quads")}
</button>
</div>
`}
<!-- ELO Display for ranked modes -->
${this.isRankedMode
? html`
<div class="text-center text-white">
${this.isLoadingElo
? html`<span class="opacity-70"
>${translateText("ranked_queue.loading_elo")}</span
>`
: this.currentPlayerElo !== null
? html`<span
>${translateText("ranked_queue.your_elo")}
<strong>${this.currentPlayerElo}</strong></span
>`
: ""}
</div>
`
: ""}
<!-- Join Queue Button -->
<button
@click=${this.inQueue
? () => this.leaveQueue()
: () => this.joinQueue()}
?disabled=${this.isConnecting}
class="w-full h-16 rounded-xl font-medium text-lg transition-opacity duration-200 ${this
.inQueue
? "bg-gradient-to-r from-red-600 to-red-500 hover:opacity-90"
: "bg-blue-600 hover:bg-blue-500"} text-white ${this.isConnecting
? "opacity-50 cursor-not-allowed"
: ""}"
class=${classMap({
"c-button": true,
"c-button--block": true,
"c-button--disabled": this.isConnecting,
})}
style=${this.inQueue ? "background: #dc2626; color: white;" : ""}
>
<div class="flex flex-col items-center justify-center">
<div>
${this.isConnecting
? translateText("ranked_queue.connecting")
: this.inQueue
? translateText("ranked_queue.leave_queue")
: translateText("ranked_queue.join_ranked_queue")}
</div>
${!this.inQueue && this.currentPlayerElo !== null
? html`<div class="text-sm mt-1 opacity-90">
${translateText("ranked_queue.your_elo")}
${this.currentPlayerElo}
</div>`
: !this.inQueue && this.isLoadingElo
? html`<div class="text-sm mt-1 opacity-90">
${translateText("ranked_queue.loading_elo")}
</div>`
: ""}
${this.error
? html`<div class="text-sm mt-1 text-red-200">
${this.error}
</div>`
: ""}
</div>
${this.isConnecting
? translateText("ranked_queue.connecting")
: this.inQueue
? translateText("ranked_queue.leave_queue")
: this.isRankedMode
? translateText("ranked_queue.join_ranked_queue")
: translateText("ranked_queue.join_queue")}
</button>
<!-- Leaderboard Toggle Button -->
<button
@click=${() => (this.showLeaderboard = !this.showLeaderboard)}
class="w-full py-2 rounded-lg bg-gray-700 hover:bg-gray-600 text-white text-sm font-medium transition-colors"
>
${this.showLeaderboard
? translateText("ranked_queue.hide_leaderboard")
: translateText("ranked_queue.view_leaderboard")}
</button>
<!-- Error Display -->
${this.error
? html`<div class="text-red-400 text-center text-sm">
${this.error}
</div>`
: ""}
<!-- Queue Status -->
${this.inQueue && this.queueStatus
? html`
<div class="text-center text-white opacity-70">
${this.queueStatus.queueSize}
${translateText("ranked_queue.players_in_queue")}
</div>
`
: ""}
<!-- Leaderboard Toggle (only for ranked modes) -->
${this.isRankedMode
? html`
<button
@click=${() => (this.showLeaderboard = !this.showLeaderboard)}
class="c-button c-button--block c-button--secondary"
>
${this.showLeaderboard
? translateText("ranked_queue.hide_leaderboard")
: translateText("ranked_queue.view_leaderboard")}
</button>
`
: ""}
<!-- Leaderboard Display -->
${this.showLeaderboard
${this.isRankedMode && this.showLeaderboard
? html`
<div
class="bg-gray-800 rounded-xl p-4 text-white max-h-96 overflow-y-auto"
class="bg-black/30 rounded-lg p-4 text-white max-h-64 overflow-y-auto"
>
${this.isLoadingLeaderboard
? html`<div class="text-center py-4">
? html`<div class="text-center py-4 opacity-70">
${translateText("ranked_queue.loading_leaderboard")}
</div>`
: this.leaderboard.length === 0
? html`<div class="text-center py-4 text-gray-400">
? html`<div class="text-center py-4 opacity-50">
${translateText("ranked_queue.no_ranked_players")}
</div>`
: html`
@@ -486,13 +613,13 @@ export class RankedQueue extends LitElement {
${this.leaderboard.slice(0, 10).map(
(entry) => html`
<div
class="flex items-center justify-between p-2 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors"
class="flex items-center justify-between p-2 bg-white/10 rounded-lg"
>
<div class="flex items-center gap-3">
<div
class="font-bold text-lg ${entry.rank <= 3
class="font-bold ${entry.rank <= 3
? "text-yellow-400"
: "text-gray-400"}"
: "opacity-60"}"
>
#${entry.rank}
</div>
@@ -500,15 +627,9 @@ export class RankedQueue extends LitElement {
<div class="font-medium">
${entry.username}
</div>
<div class="text-xs text-gray-400">
<div class="text-xs opacity-60">
${entry.gamesPlayed}
${translateText("ranked_queue.games")}
${entry.wins}${translateText(
"ranked_queue.wins_short",
)}
${entry.losses}${translateText(
"ranked_queue.losses_short",
)}
${translateText("ranked_queue.games")}
</div>
</div>
</div>
@@ -516,7 +637,7 @@ export class RankedQueue extends LitElement {
<div class="font-bold text-blue-400">
${entry.currentElo}
</div>
<div class="text-xs text-gray-400">
<div class="text-xs opacity-60">
${translateText("ranked_queue.elo")}
</div>
</div>
@@ -529,7 +650,7 @@ export class RankedQueue extends LitElement {
`
: ""}
</div>
</div>
</o-modal>
`;
}
}
+6 -1
View File
@@ -7,6 +7,7 @@ export interface MapSelectionCriteria {
playerCount: number;
gameMode: GameMode;
queueType: "ranked" | "unranked";
matchMode?: "ffa" | "team" | "duel" | "duos" | "trios" | "quads";
}
/**
@@ -16,7 +17,11 @@ export interface MapSelectionCriteria {
export function selectMapForRanked(
criteria: MapSelectionCriteria,
): GameMapType {
const { playerCount, gameMode } = criteria;
const { playerCount, gameMode, matchMode } = criteria;
if (matchMode === "duel") {
return GameMapType.Australia;
}
// Get maps that can handle this player count
const suitableMaps = getSuitableMaps(playerCount);
+26 -7
View File
@@ -1,15 +1,18 @@
import {
Difficulty,
Duos,
GameMapSize,
GameMapType,
GameMode,
GameType,
Quads,
Trios,
} from "../core/game/Game";
import { GameConfig, TeamCountConfig } from "../core/Schemas";
export interface RankedMatchConfig {
queueType: "ranked" | "unranked";
gameMode: "ffa" | "team" | "duel";
gameMode: "ffa" | "team" | "duel" | "duos" | "trios" | "quads";
playerCount: number;
teamConfig?: TeamCountConfig;
}
@@ -25,11 +28,27 @@ export function buildRankedGameConfig(
): GameConfig {
const { gameMode, playerCount } = matchConfig;
const isDuel = gameMode === "duel";
const mode = gameMode === "team" ? GameMode.Team : GameMode.FFA;
const isFFA = gameMode === "ffa";
const isTeamMode =
gameMode === "team" ||
gameMode === "duos" ||
gameMode === "trios" ||
gameMode === "quads";
const mode = isTeamMode ? GameMode.Team : GameMode.FFA;
// Determine team configuration based on game mode
let teamConfig: TeamCountConfig | undefined = matchConfig.teamConfig;
if (gameMode === "duos") {
teamConfig = Duos;
} else if (gameMode === "trios") {
teamConfig = Trios;
} else if (gameMode === "quads") {
teamConfig = Quads;
}
return {
gameMap: map,
gameMapSize: isDuel ? GameMapSize.Compact : selectMapSize(playerCount),
gameMapSize: isDuel ? GameMapSize.Normal : selectMapSize(playerCount),
gameType: GameType.Public,
gameMode: mode,
maxPlayers: playerCount,
@@ -39,21 +58,21 @@ export function buildRankedGameConfig(
disableNPCs: isDuel ? true : false,
// Donation rules
donateGold: mode === GameMode.Team,
donateTroops: mode === GameMode.Team,
donateGold: isTeamMode,
donateTroops: isTeamMode,
// Standard settings
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
randomSpawn: true,
randomSpawn: isFFA,
maxTimerValue: undefined,
// No disabled units in ranked
disabledUnits: [],
// Team configuration
playerTeams: matchConfig.teamConfig,
playerTeams: teamConfig,
};
}
+2 -1
View File
@@ -82,12 +82,13 @@ export async function startWorker() {
config,
log,
async (gameId, assignment) => {
// Select map based on player count
// Select map based on player count and mode
const selectedMap = selectMapForRanked({
playerCount: assignment.config.playerCount,
gameMode:
assignment.config.gameMode === "ffa" ? GameMode.FFA : GameMode.Team,
queueType: assignment.config.queueType,
matchMode: assignment.config.gameMode,
});
// Build full game config