mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:10:42 +00:00
JoinLobbyModal for public and private lobbies (#3097)
## Description: Replaced the src/client/JoinPrivateLobbyModal.ts with a new src/client/JoinLobbyModal.ts which handles both public + private lobbies. <img width="771" height="714" alt="image" src="https://github.com/user-attachments/assets/7ac55d91-3f0c-4f99-b960-cea9e617538d" /> also made a "connecting" to the lobby <img width="772" height="708" alt="image" src="https://github.com/user-attachments/assets/a2812462-c5f4-459a-b63a-49d93bb2a6a2" /> It also needed to be updated to address the issue with the modal using both polling + websockets ## 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 - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n
This commit is contained in:
+3
-3
@@ -179,11 +179,11 @@
|
||||
inline
|
||||
class="hidden w-full h-full page-content"
|
||||
></host-lobby-modal>
|
||||
<join-private-lobby-modal
|
||||
id="page-join-private-lobby"
|
||||
<join-lobby-modal
|
||||
id="page-join-lobby"
|
||||
inline
|
||||
class="hidden w-full h-full page-content"
|
||||
></join-private-lobby-modal>
|
||||
></join-lobby-modal>
|
||||
<territory-patterns-modal
|
||||
id="page-item-store"
|
||||
inline
|
||||
|
||||
+10
-3
@@ -21,7 +21,8 @@
|
||||
"target_dead_note": "You can't send resources to an eliminated player.",
|
||||
"none": "None",
|
||||
"copied": "Copied!",
|
||||
"click_to_copy": "Click to copy"
|
||||
"click_to_copy": "Click to copy",
|
||||
"enabled": "Enabled"
|
||||
},
|
||||
"main": {
|
||||
"title": "OpenFront (ALPHA)",
|
||||
@@ -371,18 +372,23 @@
|
||||
"disabled_units": "Disabled Units"
|
||||
},
|
||||
"public_lobby": {
|
||||
"title": "Waiting for Game Start...",
|
||||
"join": "Join next Game",
|
||||
"waiting": "players waiting",
|
||||
"teams_Duos": "{team_count} teams of 2 (Duos)",
|
||||
"teams_Trios": "{team_count} teams of 3 (Trios)",
|
||||
"teams_Quads": "{team_count} teams of 4 (Quads)",
|
||||
"waiting_for_players": "Waiting for players",
|
||||
"connecting": "Connecting to lobby...",
|
||||
"starting_in": "Starting in {time}",
|
||||
"starting_game": "Starting game…",
|
||||
"teams_hvn": "Humans vs Nations",
|
||||
"teams_hvn_detailed": "{num} Humans vs {num} Nations",
|
||||
"teams": "{num} teams",
|
||||
"players_per_team": "of {num}",
|
||||
"started": "Started"
|
||||
"started": "Started",
|
||||
"status": "Status",
|
||||
"join_timeout": "You didn't enter the game in time."
|
||||
},
|
||||
"matchmaking_modal": {
|
||||
"title": "1v1 Ranked Matchmaking (ALPHA)",
|
||||
@@ -467,7 +473,8 @@
|
||||
},
|
||||
"game_mode": {
|
||||
"ffa": "Free for All",
|
||||
"teams": "Teams"
|
||||
"teams": "Teams",
|
||||
"humans_vs_nations": "Humans vs Nations"
|
||||
},
|
||||
"public_game_modifier": {
|
||||
"random_spawn": "Random Spawn",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
GameID,
|
||||
GameRecord,
|
||||
GameStartInfo,
|
||||
LobbyInfoEvent,
|
||||
PlayerCosmeticRefs,
|
||||
PlayerRecord,
|
||||
ServerMessage,
|
||||
@@ -96,6 +97,10 @@ export function joinLobby(
|
||||
let terrainLoad: Promise<TerrainMapData> | null = null;
|
||||
|
||||
const onmessage = (message: ServerMessage) => {
|
||||
if (message.type === "lobby_info") {
|
||||
eventBus.emit(new LobbyInfoEvent(message.lobby));
|
||||
return;
|
||||
}
|
||||
if (message.type === "prestart") {
|
||||
console.log(
|
||||
`lobby: game prestarting: ${JSON.stringify(message, replacer)}`,
|
||||
|
||||
@@ -0,0 +1,848 @@
|
||||
import { html, TemplateResult } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import {
|
||||
getActiveModifiers,
|
||||
getGameModeLabel,
|
||||
normaliseMapKey,
|
||||
renderDuration,
|
||||
renderNumber,
|
||||
translateText,
|
||||
} from "../client/Utils";
|
||||
import { EventBus } from "../core/EventBus";
|
||||
import {
|
||||
ClientInfo,
|
||||
GAME_ID_REGEX,
|
||||
GameConfig,
|
||||
GameInfo,
|
||||
GameRecordSchema,
|
||||
LobbyInfoEvent,
|
||||
} from "../core/Schemas";
|
||||
import { generateID } from "../core/Util";
|
||||
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
||||
import {
|
||||
GameMapSize,
|
||||
GameMode,
|
||||
GameType,
|
||||
HumansVsNations,
|
||||
} from "../core/game/Game";
|
||||
import { getApiBase } from "./Api";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/CopyButton";
|
||||
import "./components/LobbyConfigItem";
|
||||
import "./components/LobbyPlayerView";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
|
||||
@customElement("join-lobby-modal")
|
||||
export class JoinLobbyModal extends BaseModal {
|
||||
@query("#lobbyIdInput") private lobbyIdInput!: HTMLInputElement;
|
||||
|
||||
@property({ attribute: false }) eventBus: EventBus | null = null;
|
||||
|
||||
@state() private players: ClientInfo[] = [];
|
||||
@state() private playerCount: number = 0;
|
||||
@state() private gameConfig: GameConfig | null = null;
|
||||
@state() private currentLobbyId: string = "";
|
||||
@state() private currentClientID: string = "";
|
||||
@state() private nationCount: number = 0;
|
||||
@state() private lobbyStartAt: number | null = null;
|
||||
@state() private isConnecting: boolean = true;
|
||||
@state() private lobbyCreatorClientID: string | null = null;
|
||||
|
||||
private leaveLobbyOnClose = true;
|
||||
private countdownTimerId: number | null = null;
|
||||
private handledJoinTimeout = false;
|
||||
|
||||
private readonly handleLobbyInfo = (event: LobbyInfoEvent) => {
|
||||
const lobby = event.lobby;
|
||||
if (!this.currentLobbyId || lobby.gameID !== this.currentLobbyId) {
|
||||
return;
|
||||
}
|
||||
// Only stop showing spinner when we have player info
|
||||
if (this.isConnecting && lobby.clients) {
|
||||
this.isConnecting = false;
|
||||
}
|
||||
this.updateFromLobby({
|
||||
...lobby,
|
||||
msUntilStart: lobby.msUntilStart ?? undefined,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
// Pre-join state: show lobby ID input form
|
||||
if (!this.currentLobbyId) {
|
||||
return this.renderJoinForm();
|
||||
}
|
||||
|
||||
// Post-join state: show lobby info (identical for public & private)
|
||||
const secondsRemaining =
|
||||
this.lobbyStartAt !== null
|
||||
? Math.max(0, Math.floor((this.lobbyStartAt - Date.now()) / 1000))
|
||||
: null;
|
||||
const statusLabel =
|
||||
secondsRemaining === null
|
||||
? translateText("public_lobby.waiting_for_players")
|
||||
: secondsRemaining > 0
|
||||
? translateText("public_lobby.starting_in", {
|
||||
time: renderDuration(secondsRemaining),
|
||||
})
|
||||
: translateText("public_lobby.started");
|
||||
const maxPlayers = this.gameConfig?.maxPlayers ?? 0;
|
||||
const playerCount = this.playerCount;
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden select-none"
|
||||
>
|
||||
${modalHeader({
|
||||
title: translateText("public_lobby.title"),
|
||||
onBack: () => this.closeAndLeave(),
|
||||
ariaLabel: translateText("common.close"),
|
||||
rightContent:
|
||||
this.currentLobbyId &&
|
||||
this.gameConfig?.gameType === GameType.Private
|
||||
? html`
|
||||
<copy-button .lobbyId=${this.currentLobbyId}></copy-button>
|
||||
`
|
||||
: undefined,
|
||||
})}
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4 mr-1">
|
||||
${this.isConnecting
|
||||
? html`
|
||||
<div
|
||||
class="min-h-[240px] flex flex-col items-center justify-center gap-4"
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 border-4 border-white/20 border-t-white rounded-full animate-spin"
|
||||
></div>
|
||||
<p class="text-center text-white/80 text-sm">
|
||||
${translateText("public_lobby.connecting")}
|
||||
</p>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
${this.gameConfig ? this.renderGameConfig() : html``}
|
||||
${this.players.length > 0
|
||||
? html`
|
||||
<lobby-player-view
|
||||
class="mt-6"
|
||||
.gameMode=${this.gameConfig?.gameMode ?? GameMode.FFA}
|
||||
.clients=${this.players}
|
||||
.lobbyCreatorClientID=${this.lobbyCreatorClientID}
|
||||
.currentClientID=${this.currentClientID}
|
||||
.teamCount=${this.gameConfig?.playerTeams ?? 2}
|
||||
.nationCount=${this.nationCount}
|
||||
.disableNations=${this.gameConfig?.disableNations ??
|
||||
false}
|
||||
.isCompactMap=${this.gameConfig?.gameMapSize ===
|
||||
GameMapSize.Compact}
|
||||
></lobby-player-view>
|
||||
`
|
||||
: ""}
|
||||
`}
|
||||
</div>
|
||||
|
||||
${this.gameConfig?.gameType === GameType.Private
|
||||
? html`
|
||||
<div
|
||||
class="p-6 pt-4 border-t border-white/10 bg-black/20 shrink-0"
|
||||
>
|
||||
<button
|
||||
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all shadow-lg shadow-blue-900/20 hover:shadow-blue-900/40 hover:-translate-y-0.5 active:translate-y-0 disabled:transform-none"
|
||||
disabled
|
||||
>
|
||||
${translateText("private_lobby.joined_waiting")}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div
|
||||
class="p-6 pt-4 border-t border-white/10 bg-black/20 shrink-0"
|
||||
>
|
||||
<div
|
||||
class="w-full px-4 py-3 rounded-xl border border-white/10 bg-white/5 flex items-center justify-between gap-3"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span
|
||||
class="text-[10px] font-bold uppercase tracking-widest text-white/40"
|
||||
>${translateText("public_lobby.status")}</span
|
||||
>
|
||||
<span class="text-sm font-bold text-white"
|
||||
>${statusLabel}</span
|
||||
>
|
||||
</div>
|
||||
${maxPlayers > 0
|
||||
? html`
|
||||
<div
|
||||
class="flex items-center gap-2 text-white/80 text-xs font-bold uppercase tracking-widest"
|
||||
>
|
||||
<span>${playerCount}/${maxPlayers}</span>
|
||||
<svg
|
||||
class="w-4 h-4 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.972 0 004 15v3H1v-3a3 3 0 013.75-2.906z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
`
|
||||
: html``}
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (this.inline) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return html`
|
||||
<o-modal
|
||||
?hideHeader=${true}
|
||||
?hideCloseButton=${true}
|
||||
?inline=${this.inline}
|
||||
>
|
||||
${content}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderJoinForm() {
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden select-none"
|
||||
>
|
||||
${modalHeader({
|
||||
title: translateText("private_lobby.title"),
|
||||
onBack: () => this.closeAndLeave(),
|
||||
ariaLabel: translateText("common.close"),
|
||||
})}
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4 mr-1">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
id="lobbyIdInput"
|
||||
placeholder=${translateText("private_lobby.enter_id")}
|
||||
@keyup=${this.handleChange}
|
||||
class="flex-1 px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all font-mono text-sm tracking-wider"
|
||||
/>
|
||||
<button
|
||||
@click=${this.pasteFromClipboard}
|
||||
class="px-4 py-3 bg-white/5 hover:bg-white/10 border border-white/10 hover:border-white/20 rounded-xl transition-all group"
|
||||
title=${translateText("common.paste")}
|
||||
>
|
||||
<svg
|
||||
class="text-white/60 group-hover:text-white transition-colors"
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
stroke-width="0"
|
||||
viewBox="0 0 32 32"
|
||||
height="18px"
|
||||
width="18px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M 15 3 C 13.742188 3 12.847656 3.890625 12.40625 5 L 5 5 L 5 28 L 13 28 L 13 30 L 27 30 L 27 14 L 25 14 L 25 5 L 17.59375 5 C 17.152344 3.890625 16.257813 3 15 3 Z M 15 5 C 15.554688 5 16 5.445313 16 6 L 16 7 L 19 7 L 19 9 L 11 9 L 11 7 L 14 7 L 14 6 C 14 5.445313 14.445313 5 15 5 Z M 7 7 L 9 7 L 9 11 L 21 11 L 21 7 L 23 7 L 23 14 L 13 14 L 13 26 L 7 26 Z M 15 16 L 25 16 L 25 28 L 15 28 Z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<o-button
|
||||
title=${translateText("private_lobby.join_lobby")}
|
||||
block
|
||||
@click=${this.joinLobbyFromInput}
|
||||
></o-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (this.inline) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return html`
|
||||
<o-modal
|
||||
?hideHeader=${true}
|
||||
?hideCloseButton=${true}
|
||||
?inline=${this.inline}
|
||||
>
|
||||
${content}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
public open(lobbyId: string = "", lobbyInfo?: GameInfo) {
|
||||
super.open();
|
||||
if (lobbyId) {
|
||||
this.startTrackingLobby(lobbyId, lobbyInfo);
|
||||
// If opened with lobbyInfo (public lobby case), auto-join the lobby
|
||||
if (lobbyInfo) {
|
||||
this.joinPublicLobby(lobbyId, lobbyInfo);
|
||||
} else {
|
||||
// If opened with lobbyId but no lobbyInfo (URL join case), check if active and join
|
||||
this.handleUrlJoin(lobbyId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleUrlJoin(lobbyId: string): Promise<void> {
|
||||
try {
|
||||
const gameExists = await this.checkActiveLobby(lobbyId);
|
||||
if (gameExists) return;
|
||||
|
||||
// Active lobby not found, check if it's an archived game
|
||||
switch (await this.checkArchivedGame(lobbyId)) {
|
||||
case "success":
|
||||
return;
|
||||
case "not_found":
|
||||
this.resetTrackingState();
|
||||
this.showMessage(translateText("private_lobby.not_found"), "red");
|
||||
return;
|
||||
case "version_mismatch":
|
||||
this.resetTrackingState();
|
||||
this.showMessage(
|
||||
translateText("private_lobby.version_mismatch"),
|
||||
"red",
|
||||
);
|
||||
return;
|
||||
case "error":
|
||||
this.resetTrackingState();
|
||||
this.showMessage(translateText("private_lobby.error"), "red");
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking lobby from URL:", error);
|
||||
this.resetTrackingState();
|
||||
this.showMessage(translateText("private_lobby.error"), "red");
|
||||
}
|
||||
}
|
||||
|
||||
private joinPublicLobby(lobbyId: string, lobbyInfo: GameInfo) {
|
||||
// Dispatch join-lobby event to actually connect to the lobby
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
gameID: lobbyId,
|
||||
clientID: this.currentClientID,
|
||||
publicLobbyInfo: lobbyInfo,
|
||||
} as JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private startTrackingLobby(lobbyId: string, lobbyInfo?: GameInfo) {
|
||||
this.currentLobbyId = lobbyId;
|
||||
this.currentClientID = generateID();
|
||||
this.gameConfig = null;
|
||||
this.players = [];
|
||||
this.playerCount = 0;
|
||||
this.nationCount = 0;
|
||||
this.lobbyStartAt = null;
|
||||
this.lobbyCreatorClientID = null;
|
||||
this.isConnecting = true;
|
||||
this.handledJoinTimeout = false;
|
||||
this.startLobbyUpdates();
|
||||
if (lobbyInfo) {
|
||||
this.updateFromLobby(lobbyInfo);
|
||||
// Only stop showing spinner when we have player info
|
||||
if (lobbyInfo.clients) {
|
||||
this.isConnecting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private resetTrackingState() {
|
||||
this.stopLobbyUpdates();
|
||||
this.currentLobbyId = "";
|
||||
this.currentClientID = "";
|
||||
this.isConnecting = false;
|
||||
}
|
||||
|
||||
private leaveLobby() {
|
||||
if (!this.currentLobbyId) {
|
||||
return;
|
||||
}
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("leave-lobby", {
|
||||
detail: { lobby: this.currentLobbyId },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
protected onClose(): void {
|
||||
this.clearCountdownTimer();
|
||||
this.stopLobbyUpdates();
|
||||
|
||||
if (this.leaveLobbyOnClose) {
|
||||
this.leaveLobby();
|
||||
this.updateHistory("/");
|
||||
}
|
||||
|
||||
if (this.lobbyIdInput) this.lobbyIdInput.value = "";
|
||||
this.gameConfig = null;
|
||||
this.players = [];
|
||||
this.playerCount = 0;
|
||||
this.currentLobbyId = "";
|
||||
this.currentClientID = "";
|
||||
this.nationCount = 0;
|
||||
this.lobbyStartAt = null;
|
||||
this.lobbyCreatorClientID = null;
|
||||
this.isConnecting = true;
|
||||
this.leaveLobbyOnClose = true;
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.clearCountdownTimer();
|
||||
this.stopLobbyUpdates();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
public closeAndLeave() {
|
||||
this.leaveLobby();
|
||||
try {
|
||||
this.updateHistory("/");
|
||||
} catch (error) {
|
||||
console.warn("Failed to restore URL on leave:", error);
|
||||
}
|
||||
this.leaveLobbyOnClose = false;
|
||||
this.close();
|
||||
}
|
||||
|
||||
public closeWithoutLeaving() {
|
||||
this.leaveLobbyOnClose = false;
|
||||
this.close();
|
||||
}
|
||||
|
||||
private updateHistory(url: string): void {
|
||||
if (!crazyGamesSDK.isOnCrazyGames()) {
|
||||
history.replaceState(null, "", url);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Game config rendering ---
|
||||
|
||||
private renderGameConfig(): TemplateResult {
|
||||
if (!this.gameConfig) return html``;
|
||||
|
||||
const c = this.gameConfig;
|
||||
const mapName = translateText("map." + normaliseMapKey(c.gameMap));
|
||||
const modeName = getGameModeLabel(c);
|
||||
const modifiers = getActiveModifiers(c.publicGameModifiers);
|
||||
|
||||
return html`
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
<lobby-config-item
|
||||
.label=${translateText("map.map")}
|
||||
.value=${mapName}
|
||||
></lobby-config-item>
|
||||
<lobby-config-item
|
||||
.label=${translateText("host_modal.mode")}
|
||||
.value=${modeName}
|
||||
></lobby-config-item>
|
||||
${modifiers.map(
|
||||
(m) => html`
|
||||
<lobby-config-item
|
||||
.label=${translateText(m.labelKey)}
|
||||
.value=${m.value !== undefined
|
||||
? renderNumber(m.value)
|
||||
: translateText("common.enabled")}
|
||||
></lobby-config-item>
|
||||
`,
|
||||
)}
|
||||
${c.gameMode !== GameMode.FFA &&
|
||||
c.playerTeams &&
|
||||
c.playerTeams !== HumansVsNations
|
||||
? html`
|
||||
<lobby-config-item
|
||||
.label=${typeof c.playerTeams === "string"
|
||||
? translateText("host_modal.team_type")
|
||||
: translateText("host_modal.team_count")}
|
||||
.value=${typeof c.playerTeams === "string"
|
||||
? translateText("host_modal.teams_" + c.playerTeams)
|
||||
: c.playerTeams.toString()}
|
||||
></lobby-config-item>
|
||||
`
|
||||
: html``}
|
||||
</div>
|
||||
${this.renderDisabledUnits()}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDisabledUnits(): TemplateResult {
|
||||
if (
|
||||
!this.gameConfig ||
|
||||
!this.gameConfig.disabledUnits ||
|
||||
this.gameConfig.disabledUnits.length === 0
|
||||
) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const unitKeys: Record<string, string> = {
|
||||
City: "unit_type.city",
|
||||
Port: "unit_type.port",
|
||||
"Defense Post": "unit_type.defense_post",
|
||||
"SAM Launcher": "unit_type.sam_launcher",
|
||||
"Missile Silo": "unit_type.missile_silo",
|
||||
Warship: "unit_type.warship",
|
||||
Factory: "unit_type.factory",
|
||||
"Atom Bomb": "unit_type.atom_bomb",
|
||||
"Hydrogen Bomb": "unit_type.hydrogen_bomb",
|
||||
MIRV: "unit_type.mirv",
|
||||
"Trade Ship": "player_stats_table.unit.trade",
|
||||
Transport: "player_stats_table.unit.trans",
|
||||
"MIRV Warhead": "player_stats_table.unit.mirvw",
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
|
||||
<div
|
||||
class="text-xs font-bold text-red-400 uppercase tracking-widest mb-2"
|
||||
>
|
||||
${translateText("private_lobby.disabled_units")}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
${this.gameConfig.disabledUnits.map((unit) => {
|
||||
const key = unitKeys[unit];
|
||||
const name = key ? translateText(key) : unit;
|
||||
return html`
|
||||
<span
|
||||
class="px-2 py-1 bg-red-500/20 text-red-200 text-xs rounded font-bold border border-red-500/30"
|
||||
>
|
||||
${name}
|
||||
</span>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// --- Lobby event handling ---
|
||||
|
||||
private updateFromLobby(lobby: GameInfo) {
|
||||
if (lobby.clients) {
|
||||
this.players = lobby.clients;
|
||||
this.playerCount = lobby.clients.length;
|
||||
this.lobbyCreatorClientID = lobby.clients[0]?.clientID ?? null;
|
||||
} else {
|
||||
this.players = [];
|
||||
this.playerCount = lobby.numClients ?? 0;
|
||||
}
|
||||
if (lobby.msUntilStart !== undefined) {
|
||||
this.lobbyStartAt = lobby.msUntilStart + Date.now();
|
||||
} else {
|
||||
this.lobbyStartAt = null;
|
||||
}
|
||||
this.syncCountdownTimer();
|
||||
if (lobby.gameConfig) {
|
||||
const mapChanged = this.gameConfig?.gameMap !== lobby.gameConfig.gameMap;
|
||||
this.gameConfig = lobby.gameConfig;
|
||||
if (mapChanged) {
|
||||
this.loadNationCount();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private startLobbyUpdates() {
|
||||
this.stopLobbyUpdates();
|
||||
if (!this.eventBus) {
|
||||
console.warn(
|
||||
"JoinLobbyModal: eventBus not set, cannot subscribe to lobby updates",
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.eventBus.on(LobbyInfoEvent, this.handleLobbyInfo);
|
||||
}
|
||||
|
||||
private stopLobbyUpdates() {
|
||||
this.eventBus?.off(LobbyInfoEvent, this.handleLobbyInfo);
|
||||
}
|
||||
|
||||
// --- Countdown timer ---
|
||||
|
||||
private syncCountdownTimer() {
|
||||
if (this.lobbyStartAt === null) {
|
||||
this.clearCountdownTimer();
|
||||
return;
|
||||
}
|
||||
if (this.countdownTimerId !== null) {
|
||||
return;
|
||||
}
|
||||
this.countdownTimerId = window.setInterval(() => {
|
||||
this.checkForJoinTimeout();
|
||||
this.requestUpdate();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private clearCountdownTimer() {
|
||||
if (this.countdownTimerId === null) {
|
||||
return;
|
||||
}
|
||||
clearInterval(this.countdownTimerId);
|
||||
this.countdownTimerId = null;
|
||||
}
|
||||
|
||||
private checkForJoinTimeout() {
|
||||
if (
|
||||
this.handledJoinTimeout ||
|
||||
!this.isConnecting ||
|
||||
this.lobbyStartAt === null ||
|
||||
!this.isModalOpen
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (Date.now() < this.lobbyStartAt) {
|
||||
return;
|
||||
}
|
||||
this.handledJoinTimeout = true;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
message: translateText("public_lobby.join_timeout"),
|
||||
color: "red",
|
||||
duration: 3500,
|
||||
},
|
||||
}),
|
||||
);
|
||||
this.closeAndLeave();
|
||||
}
|
||||
|
||||
// --- Nation count ---
|
||||
|
||||
private async loadNationCount() {
|
||||
if (!this.gameConfig) {
|
||||
this.nationCount = 0;
|
||||
return;
|
||||
}
|
||||
const currentMap = this.gameConfig.gameMap;
|
||||
try {
|
||||
const mapData = terrainMapFileLoader.getMapData(currentMap);
|
||||
const manifest = await mapData.manifest();
|
||||
if (this.gameConfig?.gameMap === currentMap) {
|
||||
this.nationCount = manifest.nations.length;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to load nation count", error);
|
||||
if (this.gameConfig?.gameMap === currentMap) {
|
||||
this.nationCount = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Private lobby join flow (lobby ID input) ---
|
||||
|
||||
private isValidLobbyId(value: string): boolean {
|
||||
return GAME_ID_REGEX.test(value);
|
||||
}
|
||||
|
||||
private normalizeLobbyId(input: string): string | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
const extracted = this.extractLobbyIdFromUrl(trimmed).trim();
|
||||
if (!this.isValidLobbyId(extracted)) return null;
|
||||
return extracted;
|
||||
}
|
||||
|
||||
private sanitizeForLog(value: string): string {
|
||||
return value.replace(/[\r\n]/g, "");
|
||||
}
|
||||
|
||||
private extractLobbyIdFromUrl(input: string): string {
|
||||
if (!input.startsWith("http")) {
|
||||
return input;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(input);
|
||||
const match = url.pathname.match(/game\/([^/]+)/);
|
||||
const candidate = match?.[1];
|
||||
if (candidate && GAME_ID_REGEX.test(candidate)) return candidate;
|
||||
|
||||
return input;
|
||||
} catch (error) {
|
||||
console.warn("Failed to parse lobby URL", error);
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
private setLobbyId(id: string) {
|
||||
if (this.lobbyIdInput) {
|
||||
this.lobbyIdInput.value = this.extractLobbyIdFromUrl(id);
|
||||
}
|
||||
}
|
||||
|
||||
private handleChange(e: Event) {
|
||||
const value = (e.target as HTMLInputElement).value.trim();
|
||||
this.setLobbyId(value);
|
||||
}
|
||||
|
||||
private async pasteFromClipboard() {
|
||||
try {
|
||||
const clipText = await navigator.clipboard.readText();
|
||||
this.setLobbyId(clipText);
|
||||
} catch (err) {
|
||||
console.error("Failed to read clipboard contents: ", err);
|
||||
}
|
||||
}
|
||||
|
||||
private async joinLobbyFromInput(): Promise<void> {
|
||||
const lobbyId = this.normalizeLobbyId(this.lobbyIdInput.value);
|
||||
if (!lobbyId) {
|
||||
this.showMessage(translateText("private_lobby.not_found"), "red");
|
||||
return;
|
||||
}
|
||||
|
||||
this.lobbyIdInput.value = lobbyId;
|
||||
console.log(`Joining lobby with ID: ${this.sanitizeForLog(lobbyId)}`);
|
||||
|
||||
// Initialize tracking state before checking/joining
|
||||
this.startTrackingLobby(lobbyId);
|
||||
|
||||
try {
|
||||
const gameExists = await this.checkActiveLobby(lobbyId);
|
||||
if (gameExists) return;
|
||||
|
||||
switch (await this.checkArchivedGame(lobbyId)) {
|
||||
case "success":
|
||||
return;
|
||||
case "not_found":
|
||||
this.resetTrackingState();
|
||||
this.showMessage(translateText("private_lobby.not_found"), "red");
|
||||
return;
|
||||
case "version_mismatch":
|
||||
this.resetTrackingState();
|
||||
this.showMessage(
|
||||
translateText("private_lobby.version_mismatch"),
|
||||
"red",
|
||||
);
|
||||
return;
|
||||
case "error":
|
||||
this.resetTrackingState();
|
||||
this.showMessage(translateText("private_lobby.error"), "red");
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking lobby existence:", error);
|
||||
this.resetTrackingState();
|
||||
this.showMessage(translateText("private_lobby.error"), "red");
|
||||
}
|
||||
}
|
||||
|
||||
private showMessage(message: string, color: "green" | "red" = "green") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: { message, duration: 3000, color },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async checkActiveLobby(lobbyId: string): Promise<boolean> {
|
||||
const config = await getServerConfigFromClient();
|
||||
const url = `/${config.workerPath(lobbyId)}/api/game/${lobbyId}/exists`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let gameInfo: { exists?: boolean };
|
||||
try {
|
||||
gameInfo = await response.json();
|
||||
} catch (error) {
|
||||
console.warn("Failed to parse active lobby response", error);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (gameInfo.exists) {
|
||||
this.showMessage(translateText("private_lobby.joined_waiting"));
|
||||
|
||||
// Use the clientID that was already set by startTrackingLobby in open()
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
gameID: lobbyId,
|
||||
clientID: this.currentClientID,
|
||||
} as JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Event tracking is already started by open() -> startTrackingLobby()
|
||||
// LobbyInfoEvents will update the UI as they arrive
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async checkArchivedGame(
|
||||
lobbyId: string,
|
||||
): Promise<"success" | "not_found" | "version_mismatch" | "error"> {
|
||||
const archiveResponse = await fetch(`${getApiBase()}/game/${lobbyId}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (archiveResponse.status === 404) {
|
||||
return "not_found";
|
||||
}
|
||||
if (archiveResponse.status !== 200) {
|
||||
return "error";
|
||||
}
|
||||
|
||||
const archiveData = await archiveResponse.json();
|
||||
const parsed = GameRecordSchema.safeParse(archiveData);
|
||||
if (!parsed.success) {
|
||||
return "version_mismatch";
|
||||
}
|
||||
|
||||
if (
|
||||
window.GIT_COMMIT !== "DEV" &&
|
||||
parsed.data.gitCommit !== window.GIT_COMMIT
|
||||
) {
|
||||
const safeLobbyId = this.sanitizeForLog(lobbyId);
|
||||
console.warn(
|
||||
`Git commit hash mismatch for game ${safeLobbyId}`,
|
||||
archiveData.details,
|
||||
);
|
||||
return "version_mismatch";
|
||||
}
|
||||
|
||||
// If the modal closes as part of joining the replay, do not leave/reset URL
|
||||
this.leaveLobbyOnClose = false;
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
gameID: lobbyId,
|
||||
gameRecord: parsed.data,
|
||||
clientID: this.currentClientID,
|
||||
} as JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
return "success";
|
||||
}
|
||||
}
|
||||
@@ -1,549 +0,0 @@
|
||||
import { html, TemplateResult } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { translateText } from "../client/Utils";
|
||||
import {
|
||||
ClientInfo,
|
||||
GAME_ID_REGEX,
|
||||
GameConfig,
|
||||
GameInfo,
|
||||
GameRecordSchema,
|
||||
} from "../core/Schemas";
|
||||
import { generateID } from "../core/Util";
|
||||
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
||||
import { GameMapSize, GameMode } from "../core/game/Game";
|
||||
import { getApiBase } from "./Api";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/CopyButton";
|
||||
import "./components/Difficulties";
|
||||
import "./components/LobbyPlayerView";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
@customElement("join-private-lobby-modal")
|
||||
export class JoinPrivateLobbyModal extends BaseModal {
|
||||
@query("#lobbyIdInput") private lobbyIdInput!: HTMLInputElement;
|
||||
@state() private message: string = "";
|
||||
@state() private hasJoined = false;
|
||||
@state() private players: ClientInfo[] = [];
|
||||
@state() private gameConfig: GameConfig | null = null;
|
||||
@state() private lobbyCreatorClientID: string | null = null;
|
||||
@state() private currentLobbyId: string = "";
|
||||
@state() private currentClientID: string = "";
|
||||
@state() private nationCount: number = 0;
|
||||
|
||||
private playersInterval: NodeJS.Timeout | null = null;
|
||||
private mapLoader = terrainMapFileLoader;
|
||||
|
||||
private leaveLobbyOnClose = true;
|
||||
|
||||
updated(changedProperties: Map<string | number | symbol, unknown>) {
|
||||
super.updated(changedProperties);
|
||||
}
|
||||
|
||||
render() {
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden select-none"
|
||||
>
|
||||
${modalHeader({
|
||||
title: translateText("private_lobby.title"),
|
||||
onBack: this.closeAndLeave,
|
||||
ariaLabel: translateText("common.close"),
|
||||
rightContent: this.hasJoined
|
||||
? html`
|
||||
<copy-button .lobbyId=${this.currentLobbyId}></copy-button>
|
||||
`
|
||||
: undefined,
|
||||
})}
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4 mr-1">
|
||||
${!this.hasJoined
|
||||
? html`<div class="flex flex-col gap-3">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
id="lobbyIdInput"
|
||||
placeholder=${translateText("private_lobby.enter_id")}
|
||||
@keyup=${this.handleChange}
|
||||
class="flex-1 px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all font-mono text-sm tracking-wider"
|
||||
/>
|
||||
<button
|
||||
@click=${this.pasteFromClipboard}
|
||||
class="px-4 py-3 bg-white/5 hover:bg-white/10 border border-white/10 hover:border-white/20 rounded-xl transition-all group"
|
||||
title=${translateText("common.paste")}
|
||||
>
|
||||
<svg
|
||||
class="text-white/60 group-hover:text-white transition-colors"
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
stroke-width="0"
|
||||
viewBox="0 0 32 32"
|
||||
height="18px"
|
||||
width="18px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M 15 3 C 13.742188 3 12.847656 3.890625 12.40625 5 L 5 5 L 5 28 L 13 28 L 13 30 L 27 30 L 27 14 L 25 14 L 25 5 L 17.59375 5 C 17.152344 3.890625 16.257813 3 15 3 Z M 15 5 C 15.554688 5 16 5.445313 16 6 L 16 7 L 19 7 L 19 9 L 11 9 L 11 7 L 14 7 L 14 6 C 14 5.445313 14.445313 5 15 5 Z M 7 7 L 9 7 L 9 11 L 21 11 L 21 7 L 23 7 L 23 14 L 13 14 L 13 26 L 7 26 Z M 15 16 L 25 16 L 25 28 L 15 28 Z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<o-button
|
||||
title=${translateText("private_lobby.join_lobby")}
|
||||
block
|
||||
@click=${this.joinLobby}
|
||||
></o-button>
|
||||
</div>`
|
||||
: ""}
|
||||
${this.renderGameConfig()}
|
||||
${this.hasJoined && this.players.length > 0
|
||||
? html`
|
||||
<lobby-player-view
|
||||
class="mt-6"
|
||||
.gameMode=${this.gameConfig?.gameMode ?? GameMode.FFA}
|
||||
.clients=${this.players}
|
||||
.lobbyCreatorClientID=${this.lobbyCreatorClientID}
|
||||
.currentClientID=${this.currentClientID}
|
||||
.teamCount=${this.gameConfig?.playerTeams ?? 2}
|
||||
.nationCount=${this.nationCount}
|
||||
.disableNations=${this.gameConfig?.disableNations ?? false}
|
||||
.isCompactMap=${this.gameConfig?.gameMapSize ===
|
||||
GameMapSize.Compact}
|
||||
></lobby-player-view>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
|
||||
${this.hasJoined && this.players.length > 0
|
||||
? html` <div
|
||||
class="p-6 pt-4 border-t border-white/10 bg-black/20 shrink-0"
|
||||
>
|
||||
<button
|
||||
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all shadow-lg shadow-blue-900/20 hover:shadow-blue-900/40 hover:-translate-y-0.5 active:translate-y-0 disabled:transform-none"
|
||||
disabled
|
||||
>
|
||||
${translateText("private_lobby.joined_waiting")}
|
||||
</button>
|
||||
</div>`
|
||||
: ""}
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (this.inline) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return html`
|
||||
<o-modal
|
||||
?hideHeader=${true}
|
||||
?hideCloseButton=${true}
|
||||
?inline=${this.inline}
|
||||
>
|
||||
${content}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderConfigItem(
|
||||
label: string,
|
||||
value: string | TemplateResult,
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<div
|
||||
class="bg-white/5 border border-white/10 rounded-lg p-3 flex flex-col items-center justify-center gap-1 text-center min-w-[100px]"
|
||||
>
|
||||
<span
|
||||
class="text-white/40 text-[10px] font-bold uppercase tracking-wider"
|
||||
>${label}</span
|
||||
>
|
||||
<span
|
||||
class="text-white font-bold text-sm w-full break-words hyphens-auto"
|
||||
>${value}</span
|
||||
>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderGameConfig(): TemplateResult {
|
||||
if (!this.gameConfig) return html``;
|
||||
|
||||
const c = this.gameConfig;
|
||||
const mapName = translateText(
|
||||
"map." + c.gameMap.toLowerCase().replace(/ /g, ""),
|
||||
);
|
||||
const modeName =
|
||||
c.gameMode === "Free For All"
|
||||
? translateText("game_mode.ffa")
|
||||
: translateText("game_mode.teams");
|
||||
const diffName = translateText(
|
||||
"difficulty." + c.difficulty.toLowerCase().replace(/ /g, ""),
|
||||
);
|
||||
|
||||
return html`
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
${this.renderConfigItem(translateText("map.map"), mapName)}
|
||||
${this.renderConfigItem(translateText("host_modal.mode"), modeName)}
|
||||
${this.renderConfigItem(
|
||||
translateText("difficulty.difficulty"),
|
||||
diffName,
|
||||
)}
|
||||
${this.renderConfigItem(
|
||||
translateText("host_modal.bots"),
|
||||
c.bots.toString(),
|
||||
)}
|
||||
${c.gameMode !== "Free For All" && c.playerTeams
|
||||
? this.renderConfigItem(
|
||||
typeof c.playerTeams === "string"
|
||||
? translateText("host_modal.team_type")
|
||||
: translateText("host_modal.team_count"),
|
||||
typeof c.playerTeams === "string"
|
||||
? translateText("host_modal.teams_" + c.playerTeams)
|
||||
: c.playerTeams.toString(),
|
||||
)
|
||||
: html``}
|
||||
</div>
|
||||
${this.renderDisabledUnits()}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDisabledUnits(): TemplateResult {
|
||||
if (
|
||||
!this.gameConfig ||
|
||||
!this.gameConfig.disabledUnits ||
|
||||
this.gameConfig.disabledUnits.length === 0
|
||||
) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const unitKeys: Record<string, string> = {
|
||||
City: "unit_type.city",
|
||||
Port: "unit_type.port",
|
||||
"Defense Post": "unit_type.defense_post",
|
||||
"SAM Launcher": "unit_type.sam_launcher",
|
||||
"Missile Silo": "unit_type.missile_silo",
|
||||
Warship: "unit_type.warship",
|
||||
Factory: "unit_type.factory",
|
||||
"Atom Bomb": "unit_type.atom_bomb",
|
||||
"Hydrogen Bomb": "unit_type.hydrogen_bomb",
|
||||
MIRV: "unit_type.mirv",
|
||||
"Trade Ship": "player_stats_table.unit.trade",
|
||||
Transport: "player_stats_table.unit.trans",
|
||||
"MIRV Warhead": "player_stats_table.unit.mirvw",
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
|
||||
<div
|
||||
class="text-xs font-bold text-red-400 uppercase tracking-widest mb-2"
|
||||
>
|
||||
${translateText("private_lobby.disabled_units")}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
${this.gameConfig.disabledUnits.map((unit) => {
|
||||
const key = unitKeys[unit];
|
||||
const name = key ? translateText(key) : unit;
|
||||
return html`
|
||||
<span
|
||||
class="px-2 py-1 bg-red-500/20 text-red-200 text-xs rounded font-bold border border-red-500/30"
|
||||
>
|
||||
${name}
|
||||
</span>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
public open(id: string = "") {
|
||||
super.open();
|
||||
if (id) {
|
||||
this.setLobbyId(id);
|
||||
this.joinLobby();
|
||||
}
|
||||
}
|
||||
|
||||
private leaveLobby() {
|
||||
if (!this.currentLobbyId || !this.hasJoined) {
|
||||
return;
|
||||
}
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("leave-lobby", {
|
||||
detail: { lobby: this.currentLobbyId },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
protected onClose(): void {
|
||||
if (this.lobbyIdInput) this.lobbyIdInput.value = "";
|
||||
this.gameConfig = null;
|
||||
this.players = [];
|
||||
if (this.playersInterval) {
|
||||
clearInterval(this.playersInterval);
|
||||
this.playersInterval = null;
|
||||
}
|
||||
if (this.leaveLobbyOnClose) {
|
||||
this.leaveLobby();
|
||||
// Reset URL to base when modal closes
|
||||
history.replaceState(null, "", window.location.origin + "/");
|
||||
}
|
||||
|
||||
this.hasJoined = false;
|
||||
this.message = "";
|
||||
this.currentLobbyId = "";
|
||||
this.currentClientID = "";
|
||||
this.nationCount = 0;
|
||||
|
||||
this.leaveLobbyOnClose = true;
|
||||
}
|
||||
|
||||
public closeAndLeave() {
|
||||
this.leaveLobbyOnClose = true;
|
||||
this.close();
|
||||
}
|
||||
|
||||
private isValidLobbyId(value: string): boolean {
|
||||
return GAME_ID_REGEX.test(value);
|
||||
}
|
||||
|
||||
private normalizeLobbyId(input: string): string | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
const extracted = this.extractLobbyIdFromUrl(trimmed).trim();
|
||||
if (!this.isValidLobbyId(extracted)) return null;
|
||||
return extracted;
|
||||
}
|
||||
|
||||
private sanitizeForLog(value: string): string {
|
||||
return value.replace(/[\r\n]/g, "");
|
||||
}
|
||||
|
||||
private extractLobbyIdFromUrl(input: string): string {
|
||||
if (!input.startsWith("http")) {
|
||||
return input;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(input);
|
||||
const match = url.pathname.match(/game\/([^/]+)/);
|
||||
const candidate = match?.[1];
|
||||
if (candidate && GAME_ID_REGEX.test(candidate)) return candidate;
|
||||
|
||||
return input;
|
||||
} catch (error) {
|
||||
console.warn("Failed to parse lobby URL", error);
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
private setLobbyId(id: string) {
|
||||
this.lobbyIdInput.value = this.extractLobbyIdFromUrl(id);
|
||||
}
|
||||
|
||||
private handleChange(e: Event) {
|
||||
const value = (e.target as HTMLInputElement).value.trim();
|
||||
this.setLobbyId(value);
|
||||
}
|
||||
|
||||
private async pasteFromClipboard() {
|
||||
try {
|
||||
const clipText = await navigator.clipboard.readText();
|
||||
this.setLobbyId(clipText);
|
||||
} catch (err) {
|
||||
console.error("Failed to read clipboard contents: ", err);
|
||||
}
|
||||
}
|
||||
|
||||
private async joinLobby(): Promise<void> {
|
||||
const lobbyId = this.normalizeLobbyId(this.lobbyIdInput.value);
|
||||
if (!lobbyId) {
|
||||
this.showMessage(translateText("private_lobby.not_found"), "red");
|
||||
return;
|
||||
}
|
||||
|
||||
this.lobbyIdInput.value = lobbyId;
|
||||
this.currentLobbyId = lobbyId;
|
||||
console.log(`Joining lobby with ID: ${this.sanitizeForLog(lobbyId)}`);
|
||||
|
||||
try {
|
||||
// First, check if the game exists in active lobbies
|
||||
const gameExists = await this.checkActiveLobby(lobbyId);
|
||||
if (gameExists) return;
|
||||
|
||||
// If not active, check archived games
|
||||
switch (await this.checkArchivedGame(lobbyId)) {
|
||||
case "success":
|
||||
return;
|
||||
case "not_found":
|
||||
this.showMessage(translateText("private_lobby.not_found"), "red");
|
||||
this.message = "";
|
||||
return;
|
||||
case "version_mismatch":
|
||||
this.showMessage(
|
||||
translateText("private_lobby.version_mismatch"),
|
||||
"red",
|
||||
);
|
||||
this.message = "";
|
||||
return;
|
||||
case "error":
|
||||
this.showMessage(translateText("private_lobby.error"), "red");
|
||||
this.message = "";
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking lobby existence:", error);
|
||||
this.showMessage(translateText("private_lobby.error"), "red");
|
||||
this.message = "";
|
||||
}
|
||||
}
|
||||
|
||||
private showMessage(message: string, color: "green" | "red" = "green") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: { message, duration: 3000, color },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async checkActiveLobby(lobbyId: string): Promise<boolean> {
|
||||
const config = await getServerConfigFromClient();
|
||||
const url = `/${config.workerPath(lobbyId)}/api/game/${lobbyId}/exists`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
const gameInfo = await response.json();
|
||||
|
||||
if (gameInfo.exists) {
|
||||
this.showMessage(translateText("private_lobby.joined_waiting"));
|
||||
this.message = "";
|
||||
this.hasJoined = true;
|
||||
this.currentClientID = generateID();
|
||||
|
||||
// If the modal closes as part of joining the game, do not leave the lobby
|
||||
this.leaveLobbyOnClose = false;
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
gameID: lobbyId,
|
||||
clientID: this.currentClientID,
|
||||
} as JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
this.pollPlayers();
|
||||
this.playersInterval = setInterval(() => this.pollPlayers(), 1000);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async checkArchivedGame(
|
||||
lobbyId: string,
|
||||
): Promise<"success" | "not_found" | "version_mismatch" | "error"> {
|
||||
const archiveResponse = await fetch(`${getApiBase()}/game/${lobbyId}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (archiveResponse.status === 404) {
|
||||
return "not_found";
|
||||
}
|
||||
if (archiveResponse.status !== 200) {
|
||||
return "error";
|
||||
}
|
||||
|
||||
const archiveData = await archiveResponse.json();
|
||||
const parsed = GameRecordSchema.safeParse(archiveData);
|
||||
if (!parsed.success) {
|
||||
return "version_mismatch";
|
||||
}
|
||||
|
||||
// Allow DEV to join games created with a different version for debugging.
|
||||
if (
|
||||
window.GIT_COMMIT !== "DEV" &&
|
||||
parsed.data.gitCommit !== window.GIT_COMMIT
|
||||
) {
|
||||
const safeLobbyId = this.sanitizeForLog(lobbyId);
|
||||
console.warn(
|
||||
`Git commit hash mismatch for game ${safeLobbyId}`,
|
||||
archiveData.details,
|
||||
);
|
||||
return "version_mismatch";
|
||||
}
|
||||
|
||||
this.currentClientID = generateID();
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
gameID: lobbyId,
|
||||
gameRecord: parsed.data,
|
||||
clientID: this.currentClientID,
|
||||
} as JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
return "success";
|
||||
}
|
||||
|
||||
private async pollPlayers() {
|
||||
const lobbyId = this.currentLobbyId;
|
||||
if (!lobbyId) return;
|
||||
const config = await getServerConfigFromClient();
|
||||
|
||||
fetch(`/${config.workerPath(lobbyId)}/api/game/${lobbyId}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data: GameInfo) => {
|
||||
this.lobbyCreatorClientID = data.clients?.[0]?.clientID ?? null;
|
||||
this.players = data.clients ?? [];
|
||||
if (data.gameConfig) {
|
||||
const mapChanged =
|
||||
this.gameConfig?.gameMap !== data.gameConfig.gameMap;
|
||||
this.gameConfig = data.gameConfig;
|
||||
if (mapChanged) {
|
||||
this.loadNationCount();
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error polling players:", error);
|
||||
});
|
||||
}
|
||||
|
||||
private async loadNationCount() {
|
||||
if (!this.gameConfig) {
|
||||
this.nationCount = 0;
|
||||
return;
|
||||
}
|
||||
const currentMap = this.gameConfig.gameMap;
|
||||
try {
|
||||
const mapData = this.mapLoader.getMapData(currentMap);
|
||||
const manifest = await mapData.manifest();
|
||||
// Only update if the map hasn't changed
|
||||
if (this.gameConfig?.gameMap === currentMap) {
|
||||
this.nationCount = manifest.nations.length;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to load nation count", error);
|
||||
// Only update if the map hasn't changed
|
||||
if (this.gameConfig?.gameMap === currentMap) {
|
||||
this.nationCount = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -203,7 +203,7 @@ export class LangSelector extends LitElement {
|
||||
const components = [
|
||||
"single-player-modal",
|
||||
"host-lobby-modal",
|
||||
"join-private-lobby-modal",
|
||||
"join-lobby-modal",
|
||||
"emoji-table",
|
||||
"leader-board",
|
||||
"leaderboard-tabs",
|
||||
|
||||
+42
-16
@@ -1,7 +1,12 @@
|
||||
import version from "resources/version.txt?raw";
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { EventBus } from "../core/EventBus";
|
||||
import { GAME_ID_REGEX, GameRecord, GameStartInfo } from "../core/Schemas";
|
||||
import {
|
||||
GAME_ID_REGEX,
|
||||
GameInfo,
|
||||
GameRecord,
|
||||
GameStartInfo,
|
||||
} from "../core/Schemas";
|
||||
import { GameEnv } from "../core/configuration/Config";
|
||||
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
||||
import { GameType } from "../core/game/Game";
|
||||
@@ -22,7 +27,7 @@ import "./GoogleAdElement";
|
||||
import { GutterAds } from "./GutterAds";
|
||||
import { HelpModal } from "./HelpModal";
|
||||
import { HostLobbyModal as HostPrivateLobbyModal } from "./HostLobbyModal";
|
||||
import { JoinPrivateLobbyModal } from "./JoinPrivateLobbyModal";
|
||||
import { JoinLobbyModal } from "./JoinLobbyModal";
|
||||
import "./LangSelector";
|
||||
import { LangSelector } from "./LangSelector";
|
||||
import { initLayout } from "./Layout";
|
||||
@@ -33,7 +38,7 @@ import { initNavigation } from "./Navigation";
|
||||
import "./NewsModal";
|
||||
import "./PatternInput";
|
||||
import "./PublicLobby";
|
||||
import { PublicLobby } from "./PublicLobby";
|
||||
import { PublicLobby, ShowPublicLobbyModalEvent } from "./PublicLobby";
|
||||
import { SinglePlayerModal } from "./SinglePlayerModal";
|
||||
import { TerritoryPatternsModal } from "./TerritoryPatternsModal";
|
||||
import { TokenLoginModal } from "./TokenLoginModal";
|
||||
@@ -203,6 +208,7 @@ declare global {
|
||||
// Extend the global interfaces to include your custom events
|
||||
interface DocumentEventMap {
|
||||
"join-lobby": CustomEvent<JoinLobbyEvent>;
|
||||
"show-public-lobby-modal": CustomEvent<ShowPublicLobbyModalEvent>;
|
||||
"kick-player": CustomEvent;
|
||||
"join-changed": CustomEvent;
|
||||
}
|
||||
@@ -216,6 +222,8 @@ export interface JoinLobbyEvent {
|
||||
gameStartInfo?: GameStartInfo;
|
||||
// GameRecord exists when replaying an archived game.
|
||||
gameRecord?: GameRecord;
|
||||
source?: "public" | "private" | "host" | "matchmaking" | "singleplayer";
|
||||
publicLobbyInfo?: GameInfo;
|
||||
}
|
||||
|
||||
class Client {
|
||||
@@ -228,7 +236,7 @@ class Client {
|
||||
private flagInput: FlagInput | null = null;
|
||||
|
||||
private hostModal: HostPrivateLobbyModal;
|
||||
private joinModal: JoinPrivateLobbyModal;
|
||||
private joinModal: JoinLobbyModal;
|
||||
private publicLobby: PublicLobby;
|
||||
private userSettings: UserSettings = new UserSettings();
|
||||
private patternsModal: TerritoryPatternsModal;
|
||||
@@ -302,6 +310,10 @@ class Client {
|
||||
this.gutterAds = gutterAds;
|
||||
|
||||
document.addEventListener("join-lobby", this.handleJoinLobby.bind(this));
|
||||
document.addEventListener(
|
||||
"show-public-lobby-modal",
|
||||
this.handleShowPublicLobbyModal.bind(this),
|
||||
);
|
||||
document.addEventListener("leave-lobby", this.handleLeaveLobby.bind(this));
|
||||
document.addEventListener("kick-player", this.handleKickPlayer.bind(this));
|
||||
document.addEventListener(
|
||||
@@ -504,7 +516,6 @@ class Client {
|
||||
hostLobbyButton.addEventListener("click", () => {
|
||||
if (this.usernameInput?.isValid()) {
|
||||
window.showPage?.("page-host-lobby");
|
||||
this.publicLobby.leaveLobby();
|
||||
} else {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
@@ -519,10 +530,12 @@ class Client {
|
||||
});
|
||||
|
||||
this.joinModal = document.querySelector(
|
||||
"join-private-lobby-modal",
|
||||
) as JoinPrivateLobbyModal;
|
||||
if (!this.joinModal || !(this.joinModal instanceof JoinPrivateLobbyModal)) {
|
||||
console.warn("Join private lobby modal element not found");
|
||||
"join-lobby-modal",
|
||||
) as JoinLobbyModal;
|
||||
if (!this.joinModal || !(this.joinModal instanceof JoinLobbyModal)) {
|
||||
console.warn("Join lobby modal element not found");
|
||||
} else {
|
||||
this.joinModal.eventBus = this.eventBus;
|
||||
}
|
||||
const joinPrivateLobbyButton = document.getElementById(
|
||||
"join-private-lobby-button",
|
||||
@@ -531,7 +544,7 @@ class Client {
|
||||
throw new Error("Missing join-private-lobby-button");
|
||||
joinPrivateLobbyButton.addEventListener("click", () => {
|
||||
if (this.usernameInput?.isValid()) {
|
||||
window.showPage?.("page-join-private-lobby");
|
||||
window.showPage?.("page-join-lobby");
|
||||
} else {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
@@ -631,7 +644,7 @@ class Client {
|
||||
private async handleUrl() {
|
||||
// Wait for modal custom elements to be defined
|
||||
await Promise.all([
|
||||
customElements.whenDefined("join-private-lobby-modal"),
|
||||
customElements.whenDefined("join-lobby-modal"),
|
||||
customElements.whenDefined("host-lobby-modal"),
|
||||
]);
|
||||
|
||||
@@ -644,7 +657,7 @@ class Client {
|
||||
// Wait 2 seconds to ensure all elements are actually loaded,
|
||||
// On low end-chromebooks the join modal was not registered in time.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
window.showPage?.("page-join-private-lobby");
|
||||
window.showPage?.("page-join-lobby");
|
||||
this.joinModal?.open(lobbyId);
|
||||
console.log(`CrazyGames: joining lobby ${lobbyId} from invite param`);
|
||||
return;
|
||||
@@ -733,7 +746,7 @@ class Client {
|
||||
const lobbyId =
|
||||
pathMatch && GAME_ID_REGEX.test(pathMatch[1]) ? pathMatch[1] : null;
|
||||
if (lobbyId) {
|
||||
window.showPage?.("page-join-private-lobby");
|
||||
window.showPage?.("page-join-lobby");
|
||||
this.joinModal.open(lobbyId);
|
||||
console.log(`joining lobby ${lobbyId}`);
|
||||
return;
|
||||
@@ -759,7 +772,10 @@ class Client {
|
||||
document.body.classList.remove("in-game");
|
||||
}
|
||||
const config = await getServerConfigFromClient();
|
||||
this.updateJoinUrlForShare(lobby.gameID, config);
|
||||
// Only update URL immediately for private lobbies, not public ones
|
||||
if (!lobby.publicLobbyInfo && lobby.source !== "public") {
|
||||
this.updateJoinUrlForShare(lobby.gameID, config);
|
||||
}
|
||||
|
||||
const pattern = this.userSettings.getSelectedPatternName(
|
||||
await fetchCosmetics(),
|
||||
@@ -796,10 +812,10 @@ class Client {
|
||||
document
|
||||
.getElementById("username-validation-error")
|
||||
?.classList.add("hidden");
|
||||
this.joinModal?.closeWithoutLeaving();
|
||||
[
|
||||
"single-player-modal",
|
||||
"host-lobby-modal",
|
||||
"join-private-lobby-modal",
|
||||
"game-starting-modal",
|
||||
"game-top-bar",
|
||||
"help-modal",
|
||||
@@ -884,6 +900,17 @@ class Client {
|
||||
}
|
||||
}
|
||||
|
||||
private handleShowPublicLobbyModal(
|
||||
event: CustomEvent<ShowPublicLobbyModalEvent>,
|
||||
) {
|
||||
const { lobby } = event.detail;
|
||||
console.log(`Opening JoinLobbyModal for public lobby ${lobby.gameID}`);
|
||||
|
||||
// Open the join lobby modal page and pass the lobby info
|
||||
window.showPage?.("page-join-lobby");
|
||||
this.joinModal?.open(lobby.gameID, lobby);
|
||||
}
|
||||
|
||||
private async handleLeaveLobby(/* event: CustomEvent */) {
|
||||
if (this.gameStop === null) {
|
||||
return;
|
||||
@@ -902,7 +929,6 @@ class Client {
|
||||
document.body.classList.remove("in-game");
|
||||
|
||||
crazyGamesSDK.gameplayStop();
|
||||
this.publicLobby.leaveLobby();
|
||||
}
|
||||
|
||||
private handleKickPlayer(event: CustomEvent) {
|
||||
|
||||
+46
-253
@@ -1,32 +1,25 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { renderDuration, translateText } from "../client/Utils";
|
||||
import {
|
||||
Duos,
|
||||
GameMapType,
|
||||
GameMode,
|
||||
HumansVsNations,
|
||||
PublicGameModifiers,
|
||||
Quads,
|
||||
Trios,
|
||||
} from "../core/game/Game";
|
||||
import { GameMapType } from "../core/game/Game";
|
||||
import { GameID, GameInfo } from "../core/Schemas";
|
||||
import { generateID } from "../core/Util";
|
||||
import { PublicLobbySocket } from "./LobbySocket";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
|
||||
import {
|
||||
getGameModeLabel,
|
||||
getModifierLabels,
|
||||
normaliseMapKey,
|
||||
renderDuration,
|
||||
translateText,
|
||||
} from "./Utils";
|
||||
|
||||
export interface ShowPublicLobbyModalEvent {
|
||||
lobby: GameInfo;
|
||||
}
|
||||
|
||||
@customElement("public-lobby")
|
||||
export class PublicLobby extends LitElement {
|
||||
@state() private lobbies: GameInfo[] = [];
|
||||
@state() public isLobbyHighlighted: boolean = false;
|
||||
@state() private isButtonDebounced: boolean = false;
|
||||
@state() private mapImages: Map<GameID, string> = new Map();
|
||||
@state() private joiningDotIndex: number = 0;
|
||||
|
||||
private joiningInterval: number | null = null;
|
||||
private currLobby: GameInfo | null = null;
|
||||
private debounceDelay: number = 150;
|
||||
private lobbyIDToStart = new Map<GameID, number>();
|
||||
private lobbySocket = new PublicLobbySocket((lobbies) =>
|
||||
this.handleLobbiesUpdate(lobbies),
|
||||
@@ -44,7 +37,6 @@ export class PublicLobby extends LitElement {
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.lobbySocket.stop();
|
||||
this.stopJoiningAnimation();
|
||||
}
|
||||
|
||||
private handleLobbiesUpdate(lobbies: GameInfo[]) {
|
||||
@@ -84,52 +76,16 @@ export class PublicLobby extends LitElement {
|
||||
const isStarting = timeRemaining <= 2;
|
||||
const timeDisplay = renderDuration(timeRemaining);
|
||||
|
||||
const teamCount =
|
||||
lobby.gameConfig.gameMode === GameMode.Team
|
||||
? (lobby.gameConfig.playerTeams ?? 0)
|
||||
: null;
|
||||
|
||||
const maxPlayers = lobby.gameConfig.maxPlayers ?? 0;
|
||||
const teamSize = this.getTeamSize(teamCount, maxPlayers);
|
||||
const teamTotal = this.getTeamTotal(teamCount, teamSize, maxPlayers);
|
||||
const modeLabel = this.getModeLabel(
|
||||
lobby.gameConfig.gameMode,
|
||||
teamCount,
|
||||
teamTotal,
|
||||
teamSize,
|
||||
);
|
||||
// True when the detail label already includes the full mode text.
|
||||
const { label: teamDetailLabel, isFullLabel: isTeamDetailFullLabel } =
|
||||
this.getTeamDetailLabel(
|
||||
lobby.gameConfig.gameMode,
|
||||
teamCount,
|
||||
teamTotal,
|
||||
teamSize,
|
||||
);
|
||||
|
||||
let fullModeLabel = modeLabel;
|
||||
if (teamDetailLabel) {
|
||||
fullModeLabel = isTeamDetailFullLabel
|
||||
? teamDetailLabel
|
||||
: `${modeLabel} ${teamDetailLabel}`;
|
||||
}
|
||||
|
||||
const modifierLabel = this.getModifierLabels(
|
||||
const modeLabel = getGameModeLabel(lobby.gameConfig);
|
||||
const modifierLabels = getModifierLabels(
|
||||
lobby.gameConfig.publicGameModifiers,
|
||||
);
|
||||
|
||||
const mapImageSrc = this.mapImages.get(lobby.gameID);
|
||||
|
||||
return html`
|
||||
<button
|
||||
@click=${() => this.lobbyClicked(lobby)}
|
||||
?disabled=${this.isButtonDebounced}
|
||||
class="group relative isolate flex flex-col w-full h-80 lg:h-96 overflow-hidden rounded-2xl transition-all duration-200 bg-[#3d7bab] ${this
|
||||
.isLobbyHighlighted
|
||||
? "ring-2 ring-blue-600 scale-[1.01] opacity-70"
|
||||
: "hover:scale-[1.01]"} active:scale-[0.98] ${this.isButtonDebounced
|
||||
? "cursor-not-allowed"
|
||||
: ""}"
|
||||
class="group relative isolate flex flex-col w-full h-80 lg:h-96 overflow-hidden rounded-2xl transition-all duration-200 bg-[#3d7bab] hover:scale-[1.01] active:scale-[0.98] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/50"
|
||||
>
|
||||
<div class="font-sans w-full h-full flex flex-col">
|
||||
<!-- Main card gradient - stops before text -->
|
||||
@@ -149,11 +105,11 @@ export class PublicLobby extends LitElement {
|
||||
</div>
|
||||
|
||||
<!-- Mode Badge in top left -->
|
||||
${fullModeLabel
|
||||
${modeLabel
|
||||
? html`<span
|
||||
class="absolute top-4 left-4 px-4 py-1 rounded font-bold text-sm lg:text-base uppercase tracking-widest z-30 bg-slate-800 text-white ring-1 ring-white/10 shadow-sm"
|
||||
>
|
||||
${fullModeLabel}
|
||||
${modeLabel}
|
||||
</span>`
|
||||
: ""}
|
||||
|
||||
@@ -175,11 +131,11 @@ export class PublicLobby extends LitElement {
|
||||
<!-- Content Banner -->
|
||||
<div class="absolute bottom-0 left-0 right-0 z-20">
|
||||
<!-- Modifier badges placed just above the gradient overlay -->
|
||||
${modifierLabel.length > 0
|
||||
${modifierLabels.length > 0
|
||||
? html`<div
|
||||
class="absolute -top-8 left-4 z-30 flex gap-2 flex-wrap"
|
||||
>
|
||||
${modifierLabel.map(
|
||||
${modifierLabels.map(
|
||||
(label) => html`
|
||||
<span
|
||||
class="px-2 py-0.5 rounded text-xs font-medium uppercase tracking-wide bg-purple-600 text-white"
|
||||
@@ -200,19 +156,10 @@ export class PublicLobby extends LitElement {
|
||||
<!-- Header row: Status/Join on left, Player Count on right -->
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="text-base uppercase tracking-widest text-white">
|
||||
${this.currLobby
|
||||
? isStarting
|
||||
? html`<span class="text-green-400 animate-pulse"
|
||||
>${translateText("public_lobby.starting_game")}</span
|
||||
>`
|
||||
: html`<span class="text-orange-400"
|
||||
>${translateText("public_lobby.waiting_for_players")}
|
||||
${[0, 1, 2]
|
||||
.map((i) =>
|
||||
i === this.joiningDotIndex ? "•" : "·",
|
||||
)
|
||||
.join("")}</span
|
||||
>`
|
||||
${isStarting
|
||||
? html`<span class="text-green-400 animate-pulse"
|
||||
>${translateText("public_lobby.starting_game")}</span
|
||||
>`
|
||||
: html`${translateText("public_lobby.join")}`}
|
||||
</div>
|
||||
|
||||
@@ -237,7 +184,7 @@ export class PublicLobby extends LitElement {
|
||||
class="text-2xl lg:text-3xl font-bold text-white leading-none uppercase tracking-widest w-full"
|
||||
>
|
||||
${translateText(
|
||||
`map.${lobby.gameConfig.gameMap.toLowerCase().replace(/[\s.]+/g, "")}`,
|
||||
`map.${normaliseMapKey(lobby.gameConfig.gameMap)}`,
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -249,190 +196,36 @@ export class PublicLobby extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
leaveLobby() {
|
||||
this.isLobbyHighlighted = false;
|
||||
this.currLobby = null;
|
||||
this.stopJoiningAnimation();
|
||||
}
|
||||
|
||||
public stop() {
|
||||
this.lobbySocket.stop();
|
||||
this.isLobbyHighlighted = false;
|
||||
this.currLobby = null;
|
||||
this.stopJoiningAnimation();
|
||||
}
|
||||
|
||||
private startJoiningAnimation() {
|
||||
if (this.joiningInterval !== null) return;
|
||||
|
||||
this.joiningDotIndex = 0;
|
||||
this.joiningInterval = window.setInterval(() => {
|
||||
this.joiningDotIndex = (this.joiningDotIndex + 1) % 3;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
private stopJoiningAnimation() {
|
||||
if (this.joiningInterval !== null) {
|
||||
clearInterval(this.joiningInterval);
|
||||
this.joiningInterval = null;
|
||||
}
|
||||
this.joiningDotIndex = 0;
|
||||
}
|
||||
|
||||
private getTeamSize(
|
||||
teamCount: number | string | null,
|
||||
maxPlayers: number,
|
||||
): number | undefined {
|
||||
if (typeof teamCount === "string") {
|
||||
if (teamCount === Duos) return 2;
|
||||
if (teamCount === Trios) return 3;
|
||||
if (teamCount === Quads) return 4;
|
||||
if (teamCount === HumansVsNations) return maxPlayers;
|
||||
return undefined;
|
||||
}
|
||||
if (typeof teamCount === "number" && teamCount > 0) {
|
||||
return Math.floor(maxPlayers / teamCount);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private getTeamTotal(
|
||||
teamCount: number | string | null,
|
||||
teamSize: number | undefined,
|
||||
maxPlayers: number,
|
||||
): number | undefined {
|
||||
if (typeof teamCount === "number") return teamCount;
|
||||
if (teamCount === HumansVsNations) return 2;
|
||||
if (teamSize && teamSize > 0) return Math.floor(maxPlayers / teamSize);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private getModeLabel(
|
||||
gameMode: GameMode,
|
||||
teamCount: number | string | null,
|
||||
teamTotal: number | undefined,
|
||||
teamSize: number | undefined,
|
||||
): string {
|
||||
if (gameMode !== GameMode.Team) return translateText("game_mode.ffa");
|
||||
if (teamCount === HumansVsNations && teamSize !== undefined)
|
||||
return translateText("public_lobby.teams_hvn_detailed", {
|
||||
num: teamSize,
|
||||
});
|
||||
const totalTeams =
|
||||
teamTotal ?? (typeof teamCount === "number" ? teamCount : 0);
|
||||
return translateText("public_lobby.teams", { num: totalTeams });
|
||||
}
|
||||
|
||||
private getTeamDetailLabel(
|
||||
gameMode: GameMode,
|
||||
teamCount: number | string | null,
|
||||
teamTotal: number | undefined,
|
||||
teamSize: number | undefined,
|
||||
): { label: string | null; isFullLabel: boolean } {
|
||||
if (gameMode !== GameMode.Team) {
|
||||
return { label: null, isFullLabel: false };
|
||||
}
|
||||
|
||||
if (typeof teamCount === "string" && teamCount === HumansVsNations) {
|
||||
return { label: null, isFullLabel: false };
|
||||
}
|
||||
|
||||
if (typeof teamCount === "string") {
|
||||
const teamKey = `public_lobby.teams_${teamCount}`;
|
||||
// translateText returns the key when a translation is missing.
|
||||
const maybeTranslated = translateText(teamKey, {
|
||||
team_count: teamTotal ?? 0,
|
||||
});
|
||||
if (maybeTranslated !== teamKey) {
|
||||
return { label: maybeTranslated, isFullLabel: true };
|
||||
}
|
||||
}
|
||||
|
||||
if (teamTotal !== undefined && teamSize !== undefined) {
|
||||
// Fallback when there's no specific team label translation.
|
||||
return {
|
||||
label: translateText("public_lobby.players_per_team", {
|
||||
num: teamSize,
|
||||
}),
|
||||
isFullLabel: false,
|
||||
};
|
||||
}
|
||||
|
||||
return { label: null, isFullLabel: false };
|
||||
}
|
||||
|
||||
private getModifierLabels(
|
||||
publicGameModifiers: PublicGameModifiers | undefined,
|
||||
): string[] {
|
||||
if (!publicGameModifiers) {
|
||||
return [];
|
||||
}
|
||||
const labels: string[] = [];
|
||||
if (publicGameModifiers.isRandomSpawn) {
|
||||
labels.push(translateText("public_game_modifier.random_spawn"));
|
||||
}
|
||||
if (publicGameModifiers.isCompact) {
|
||||
labels.push(translateText("public_game_modifier.compact_map"));
|
||||
}
|
||||
if (publicGameModifiers.isCrowded) {
|
||||
labels.push(translateText("public_game_modifier.crowded"));
|
||||
}
|
||||
if (publicGameModifiers.startingGold) {
|
||||
labels.push(translateText("public_game_modifier.starting_gold"));
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
private lobbyClicked(lobby: GameInfo) {
|
||||
if (this.isButtonDebounced) return;
|
||||
|
||||
this.isButtonDebounced = true;
|
||||
setTimeout(() => {
|
||||
this.isButtonDebounced = false;
|
||||
}, this.debounceDelay);
|
||||
|
||||
if (this.currLobby === null) {
|
||||
// Validate username only when joining a new lobby
|
||||
const usernameInput = document.querySelector("username-input") as any;
|
||||
if (
|
||||
usernameInput &&
|
||||
typeof usernameInput.isValid === "function" &&
|
||||
!usernameInput.isValid()
|
||||
) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
message: usernameInput.validationError,
|
||||
color: "red",
|
||||
duration: 3000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLobbyHighlighted = true;
|
||||
this.currLobby = lobby;
|
||||
this.startJoiningAnimation();
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("join-lobby", {
|
||||
// Validate username before opening the modal
|
||||
const usernameInput = document.querySelector("username-input") as any;
|
||||
if (
|
||||
usernameInput &&
|
||||
typeof usernameInput.isValid === "function" &&
|
||||
!usernameInput.isValid()
|
||||
) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
gameID: lobby.gameID,
|
||||
clientID: generateID(),
|
||||
} as JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
message: usernameInput.validationError,
|
||||
color: "red",
|
||||
duration: 3000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("leave-lobby", {
|
||||
detail: { lobby: this.currLobby },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
this.leaveLobby();
|
||||
return;
|
||||
}
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("show-public-lobby-modal", {
|
||||
detail: { lobby } as ShowPublicLobbyModalEvent,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+126
-1
@@ -1,9 +1,134 @@
|
||||
import IntlMessageFormat from "intl-messageformat";
|
||||
import { MessageType } from "../core/game/Game";
|
||||
import {
|
||||
Duos,
|
||||
GameMode,
|
||||
HumansVsNations,
|
||||
MessageType,
|
||||
PublicGameModifiers,
|
||||
Quads,
|
||||
Trios,
|
||||
} from "../core/game/Game";
|
||||
import { GameConfig } from "../core/Schemas";
|
||||
import type { LangSelector } from "./LangSelector";
|
||||
|
||||
export const TUTORIAL_VIDEO_URL = "https://www.youtube.com/embed/EN2oOog3pSs";
|
||||
|
||||
export function normaliseMapKey(mapName: string): string {
|
||||
return mapName.toLowerCase().replace(/[\s.]+/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a display label for the game mode (e.g. "FFA", "4 Teams", "Duos").
|
||||
*/
|
||||
export function getGameModeLabel(gameConfig: GameConfig): string {
|
||||
const { gameMode, playerTeams, maxPlayers } = gameConfig;
|
||||
|
||||
if (gameMode !== GameMode.Team) {
|
||||
return translateText("game_mode.ffa");
|
||||
}
|
||||
|
||||
// Humans vs Nations
|
||||
if (playerTeams === HumansVsNations) {
|
||||
return translateText("public_lobby.teams_hvn_detailed", {
|
||||
num: maxPlayers ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Named team types (Duos, Trios, Quads)
|
||||
if (typeof playerTeams === "string") {
|
||||
const teamKey = `public_lobby.teams_${playerTeams}`;
|
||||
const teamCount = getTeamCount(playerTeams, maxPlayers ?? 0);
|
||||
const translated = translateText(teamKey, { team_count: teamCount });
|
||||
if (translated !== teamKey) {
|
||||
return translated;
|
||||
}
|
||||
}
|
||||
|
||||
// Numeric team count
|
||||
const teamCount =
|
||||
typeof playerTeams === "number"
|
||||
? playerTeams
|
||||
: getTeamCount(playerTeams, maxPlayers ?? 0);
|
||||
return translateText("public_lobby.teams", { num: teamCount });
|
||||
}
|
||||
|
||||
function getTeamCount(
|
||||
playerTeams: string | number | undefined,
|
||||
maxPlayers: number,
|
||||
): number {
|
||||
if (typeof playerTeams === "number") return playerTeams;
|
||||
const teamSize = getTeamSize(playerTeams, maxPlayers);
|
||||
return teamSize > 0 ? Math.floor(maxPlayers / teamSize) : 0;
|
||||
}
|
||||
|
||||
function getTeamSize(
|
||||
playerTeams: string | number | undefined,
|
||||
maxPlayers: number,
|
||||
): number {
|
||||
if (playerTeams === Duos) return 2;
|
||||
if (playerTeams === Trios) return 3;
|
||||
if (playerTeams === Quads) return 4;
|
||||
if (playerTeams === HumansVsNations) return maxPlayers;
|
||||
if (typeof playerTeams === "number" && playerTeams > 0) {
|
||||
return Math.floor(maxPlayers / playerTeams);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export interface ModifierInfo {
|
||||
/** Translation key for detailed label (e.g. "host_modal.random_spawn") */
|
||||
labelKey: string;
|
||||
/** Translation key for badge/short label (e.g. "public_game_modifier.random_spawn") */
|
||||
badgeKey: string;
|
||||
/** The raw value if applicable (e.g. startingGold amount) */
|
||||
value?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns structured modifier info for both detailed config display and badges.
|
||||
*/
|
||||
export function getActiveModifiers(
|
||||
modifiers: PublicGameModifiers | undefined,
|
||||
): ModifierInfo[] {
|
||||
if (!modifiers) return [];
|
||||
const result: ModifierInfo[] = [];
|
||||
if (modifiers.isRandomSpawn) {
|
||||
result.push({
|
||||
labelKey: "host_modal.random_spawn",
|
||||
badgeKey: "public_game_modifier.random_spawn",
|
||||
});
|
||||
}
|
||||
if (modifiers.isCompact) {
|
||||
result.push({
|
||||
labelKey: "host_modal.compact_map",
|
||||
badgeKey: "public_game_modifier.compact_map",
|
||||
});
|
||||
}
|
||||
if (modifiers.isCrowded) {
|
||||
result.push({
|
||||
labelKey: "host_modal.crowded",
|
||||
badgeKey: "public_game_modifier.crowded",
|
||||
});
|
||||
}
|
||||
if (modifiers.startingGold) {
|
||||
result.push({
|
||||
labelKey: "host_modal.starting_gold",
|
||||
badgeKey: "public_game_modifier.starting_gold",
|
||||
value: modifiers.startingGold,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of translated modifier labels for badge display.
|
||||
*/
|
||||
export function getModifierLabels(
|
||||
modifiers: PublicGameModifiers | undefined,
|
||||
): string[] {
|
||||
return getActiveModifiers(modifiers).map((m) => translateText(m.badgeKey));
|
||||
}
|
||||
|
||||
export function renderDuration(totalSeconds: number): string {
|
||||
if (totalSeconds <= 0) return "0s";
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { LitElement, TemplateResult, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
@customElement("lobby-config-item")
|
||||
export class LobbyConfigItem extends LitElement {
|
||||
@property({ type: String }) label = "";
|
||||
@property({ attribute: false }) value: string | TemplateResult = "";
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
class="bg-white/5 border border-white/10 rounded-lg p-3 flex flex-col items-center justify-center gap-1 text-center min-w-[100px]"
|
||||
>
|
||||
<span
|
||||
class="text-white/40 text-[10px] font-bold uppercase tracking-wider"
|
||||
>${this.label}</span
|
||||
>
|
||||
<span
|
||||
class="text-white font-bold text-sm w-full break-words hyphens-auto"
|
||||
>${this.value}</span
|
||||
>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
+18
-2
@@ -6,6 +6,7 @@ import {
|
||||
PatternDataSchema,
|
||||
PatternNameSchema,
|
||||
} from "./CosmeticSchemas";
|
||||
import type { GameEvent } from "./EventBus";
|
||||
import {
|
||||
AllPlayers,
|
||||
Difficulty,
|
||||
@@ -105,7 +106,8 @@ export type ServerMessage =
|
||||
| ServerPingMessage
|
||||
| ServerDesyncMessage
|
||||
| ServerPrestartMessage
|
||||
| ServerErrorMessage;
|
||||
| ServerErrorMessage
|
||||
| ServerLobbyInfoMessage;
|
||||
|
||||
export type ServerTurnMessage = z.infer<typeof ServerTurnMessageSchema>;
|
||||
export type ServerStartGameMessage = z.infer<
|
||||
@@ -115,6 +117,9 @@ export type ServerPingMessage = z.infer<typeof ServerPingMessageSchema>;
|
||||
export type ServerDesyncMessage = z.infer<typeof ServerDesyncSchema>;
|
||||
export type ServerPrestartMessage = z.infer<typeof ServerPrestartMessageSchema>;
|
||||
export type ServerErrorMessage = z.infer<typeof ServerErrorSchema>;
|
||||
export type ServerLobbyInfoMessage = z.infer<
|
||||
typeof ServerLobbyInfoMessageSchema
|
||||
>;
|
||||
export type ClientSendWinnerMessage = z.infer<typeof ClientSendWinnerSchema>;
|
||||
export type ClientPingMessage = z.infer<typeof ClientPingMessageSchema>;
|
||||
export type ClientIntentMessage = z.infer<typeof ClientIntentMessageSchema>;
|
||||
@@ -152,6 +157,11 @@ export interface GameInfo {
|
||||
msUntilStart?: number;
|
||||
gameConfig?: GameConfig;
|
||||
}
|
||||
|
||||
export class LobbyInfoEvent implements GameEvent {
|
||||
constructor(public lobby: GameInfo) {}
|
||||
}
|
||||
|
||||
export interface ClientInfo {
|
||||
clientID: ClientID;
|
||||
username: string;
|
||||
@@ -212,7 +222,7 @@ export const GameConfigSchema = z.object({
|
||||
|
||||
export const TeamSchema = z.string();
|
||||
|
||||
const SafeString = z
|
||||
export const SafeString = z
|
||||
.string()
|
||||
.regex(
|
||||
/^([a-zA-Z0-9\s.,!?@#$%&*()\-_+=[\]{}|;:"'/\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff]|[üÜ])*$/u,
|
||||
@@ -539,6 +549,11 @@ export const ServerErrorSchema = z.object({
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ServerLobbyInfoMessageSchema = z.object({
|
||||
type: z.literal("lobby_info"),
|
||||
lobby: GameInfoSchema,
|
||||
});
|
||||
|
||||
export const ServerMessageSchema = z.discriminatedUnion("type", [
|
||||
ServerTurnMessageSchema,
|
||||
ServerPrestartMessageSchema,
|
||||
@@ -546,6 +561,7 @@ export const ServerMessageSchema = z.discriminatedUnion("type", [
|
||||
ServerPingMessageSchema,
|
||||
ServerDesyncSchema,
|
||||
ServerErrorSchema,
|
||||
ServerLobbyInfoMessageSchema,
|
||||
]);
|
||||
|
||||
//
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
PlayerRecord,
|
||||
ServerDesyncSchema,
|
||||
ServerErrorMessage,
|
||||
ServerLobbyInfoMessage,
|
||||
ServerPrestartMessageSchema,
|
||||
ServerStartGameMessage,
|
||||
ServerTurnMessage,
|
||||
@@ -78,6 +79,8 @@ export class GameServer {
|
||||
|
||||
public desyncCount = 0;
|
||||
|
||||
private lobbyInfoIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
readonly log_: Logger,
|
||||
@@ -232,6 +235,7 @@ export class GameServer {
|
||||
this.markClientDisconnected(client.clientID, false);
|
||||
this.allClients.set(client.clientID, client);
|
||||
this.addListeners(client);
|
||||
this.startLobbyInfoBroadcast();
|
||||
|
||||
// In case a client joined the game late and missed the start message.
|
||||
if (this._hasStarted) {
|
||||
@@ -280,6 +284,7 @@ export class GameServer {
|
||||
|
||||
client.ws = ws;
|
||||
this.addListeners(client);
|
||||
this.startLobbyInfoBroadcast();
|
||||
|
||||
if (this._hasStarted) {
|
||||
this.sendStartGameMsg(client.ws, msg.lastTurn);
|
||||
@@ -542,6 +547,47 @@ export class GameServer {
|
||||
});
|
||||
}
|
||||
|
||||
private startLobbyInfoBroadcast() {
|
||||
if (this._hasStarted || this._hasEnded) {
|
||||
return;
|
||||
}
|
||||
if (this.lobbyInfoIntervalId !== null) {
|
||||
return;
|
||||
}
|
||||
this.broadcastLobbyInfo();
|
||||
this.lobbyInfoIntervalId = setInterval(() => {
|
||||
if (
|
||||
this._hasStarted ||
|
||||
this._hasEnded ||
|
||||
this.activeClients.length === 0
|
||||
) {
|
||||
this.stopLobbyInfoBroadcast();
|
||||
return;
|
||||
}
|
||||
this.broadcastLobbyInfo();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private stopLobbyInfoBroadcast() {
|
||||
if (this.lobbyInfoIntervalId === null) {
|
||||
return;
|
||||
}
|
||||
clearInterval(this.lobbyInfoIntervalId);
|
||||
this.lobbyInfoIntervalId = null;
|
||||
}
|
||||
|
||||
private broadcastLobbyInfo() {
|
||||
const msg = JSON.stringify({
|
||||
type: "lobby_info",
|
||||
lobby: this.gameInfo(),
|
||||
} satisfies ServerLobbyInfoMessage);
|
||||
this.activeClients.forEach((c) => {
|
||||
if (c.ws.readyState === WebSocket.OPEN) {
|
||||
c.ws.send(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public start() {
|
||||
if (this._hasStarted || this._hasEnded) {
|
||||
return;
|
||||
@@ -771,12 +817,15 @@ export class GameServer {
|
||||
clientID: c.clientID,
|
||||
})),
|
||||
gameConfig: this.gameConfig,
|
||||
msUntilStart: this.isPublic()
|
||||
? this.createdAt + this.config.gameCreationRate()
|
||||
: undefined,
|
||||
msUntilStart: this.isPublic() ? this.getMsUntilStart() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private getMsUntilStart(): number {
|
||||
const startTime = this.createdAt + this.config.gameCreationRate();
|
||||
return Math.max(0, startTime - Date.now());
|
||||
}
|
||||
|
||||
public isPublic(): boolean {
|
||||
return this.gameConfig.gameType === GameType.Public;
|
||||
}
|
||||
|
||||
@@ -267,7 +267,7 @@ async function fetchLobbies(): Promise<number> {
|
||||
gameID: gi.gameID,
|
||||
numClients: gi?.clients?.length ?? 0,
|
||||
gameConfig: gi.gameConfig,
|
||||
msUntilStart: (gi.msUntilStart ?? Date.now()) - Date.now(),
|
||||
msUntilStart: gi.msUntilStart,
|
||||
} as GameInfo;
|
||||
});
|
||||
|
||||
|
||||
+20
-7
@@ -13,6 +13,24 @@ const __dirname = path.dirname(__filename);
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
const isProduction = mode === "production";
|
||||
// In dev, redirect visits to /w*/game/* to "/" so Vite serves the index.html.
|
||||
const devGameHtmlBypass = (req?: {
|
||||
url?: string;
|
||||
method?: string;
|
||||
headers?: { accept?: string | string[] };
|
||||
}) => {
|
||||
if (req?.method !== "GET") return undefined;
|
||||
const accept = req.headers?.accept;
|
||||
const acceptValue = Array.isArray(accept)
|
||||
? accept.join(",")
|
||||
: (accept ?? "");
|
||||
if (!acceptValue.includes("text/html")) return undefined;
|
||||
if (!req.url) return undefined;
|
||||
if (/^\/w\d+\/game\/[^/]+/.test(req.url)) {
|
||||
return "/";
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
test: {
|
||||
@@ -103,6 +121,7 @@ export default defineConfig(({ mode }) => {
|
||||
ws: true,
|
||||
secure: false,
|
||||
changeOrigin: true,
|
||||
bypass: (req) => devGameHtmlBypass(req),
|
||||
rewrite: (path) => path.replace(/^\/w0/, ""),
|
||||
},
|
||||
"/w1": {
|
||||
@@ -110,15 +129,9 @@ export default defineConfig(({ mode }) => {
|
||||
ws: true,
|
||||
secure: false,
|
||||
changeOrigin: true,
|
||||
bypass: (req) => devGameHtmlBypass(req),
|
||||
rewrite: (path) => path.replace(/^\/w1/, ""),
|
||||
},
|
||||
"/w2": {
|
||||
target: "ws://localhost:3003",
|
||||
ws: true,
|
||||
secure: false,
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/w2/, ""),
|
||||
},
|
||||
// API proxies
|
||||
"/api": {
|
||||
target: "http://localhost:3000",
|
||||
|
||||
Reference in New Issue
Block a user