matchmaking

This commit is contained in:
evanpelle
2026-01-30 16:23:01 -08:00
parent 6c2e0d1528
commit fac5611c75
5 changed files with 326 additions and 7 deletions
+3 -1
View File
@@ -342,7 +342,9 @@
"connecting": "Connecting to matchmaking server...",
"searching": "Searching for game...",
"waiting_for_game": "Waiting for game to start...",
"elo": "Your ELO: {elo}"
"elo": "Your ELO: {elo}",
"ad_blocked_title": "Ad Blocker Detected",
"ad_blocked_message": "Please disable your ad blocker or make a purchase to play ranked games."
},
"username": {
"enter_username": "Enter your username",
+5 -3
View File
@@ -69,6 +69,7 @@ export function joinLobby(
lobbyConfig: LobbyConfig,
onPrestart: () => void,
onJoin: () => void,
waitBeforeJoin: Promise<void> = Promise.resolve(),
): (force?: boolean) => boolean {
console.log(
`joining lobby: gameID: ${lobbyConfig.gameID}, clientID: ${lobbyConfig.clientID}`,
@@ -95,7 +96,7 @@ export function joinLobby(
};
let terrainLoad: Promise<TerrainMapData> | null = null;
const onmessage = (message: ServerMessage) => {
const onmessage = async (message: ServerMessage) => {
if (message.type === "prestart") {
console.log(
`lobby: game prestarting: ${JSON.stringify(message, replacer)}`,
@@ -105,14 +106,15 @@ export function joinLobby(
message.gameMapSize,
terrainMapFileLoader,
);
onPrestart();
waitBeforeJoin.then(onPrestart);
}
if (message.type === "start") {
// Trigger prestart for singleplayer games
onPrestart();
waitBeforeJoin.then(onPrestart);
console.log(
`lobby: game started: ${JSON.stringify(message, replacer, 2)}`,
);
await waitBeforeJoin;
onJoin();
// For multiplayer games, GameStartInfo is not known until game starts.
lobbyConfig.gameStartInfo = message.gameStartInfo;
+21
View File
@@ -178,6 +178,24 @@ declare global {
slots?: any;
};
spaNewPage: (url?: string) => void;
// Video ad methods
onPlayerReady: (() => void) | null;
addUnits: (units: Array<{ type: string }>) => Promise<void>;
displayUnits: () => void;
};
Bolt: {
on: (unitType: string, event: string, callback: () => void) => void;
BOLT_AD_REQUEST_START: string;
BOLT_AD_IMPRESSION: string;
BOLT_AD_STARTED: string;
BOLT_FIRST_QUARTILE: string;
BOLT_MIDPOINT: string;
BOLT_THIRD_QUARTILE: string;
BOLT_AD_COMPLETE: string;
BOLT_AD_ERROR: string;
BOLT_AD_PAUSED: string;
BOLT_AD_CLICKED: string;
SHOW_HIDDEN_CONTAINER: string;
};
showPage?: (pageId: string) => void;
}
@@ -198,6 +216,8 @@ export interface JoinLobbyEvent {
gameStartInfo?: GameStartInfo;
// GameRecord exists when replaying an archived game.
gameRecord?: GameRecord;
waitBeforeJoin?: Promise<void>;
}
class Client {
@@ -850,6 +870,7 @@ class Client {
// Store current URL for popstate confirmation
this.currentUrl = window.location.href;
},
lobby.waitBeforeJoin,
);
}
+110 -3
View File
@@ -9,6 +9,7 @@ import { BaseModal } from "./components/BaseModal";
import "./components/Difficulties";
import "./components/PatternButton";
import { modalHeader } from "./components/ui/ModalHeader";
import "./components/VideoAd";
import { JoinLobbyEvent } from "./Main";
import { translateText } from "./Utils";
@@ -19,7 +20,12 @@ export class MatchmakingModal extends BaseModal {
@state() private connected = false;
@state() private socket: WebSocket | null = null;
@state() private gameID: string | null = null;
@state() private videoAdComplete = false;
@state() private adBlocked = false;
private elo: number | "unknown" = "unknown";
private adCompleteResolve: (() => void) | null = null;
private adMidpointResolve: (() => void) | null = null;
private adMidpointReached = false;
constructor() {
super();
@@ -30,9 +36,89 @@ export class MatchmakingModal extends BaseModal {
return this;
}
private handleAdComplete = () => {
this.videoAdComplete = true;
if (this.adCompleteResolve) {
this.adCompleteResolve();
this.adCompleteResolve = null;
}
this.requestUpdate();
};
private handleAdMidpoint = () => {
this.adMidpointReached = true;
if (this.adMidpointResolve) {
this.adMidpointResolve();
this.adMidpointResolve = null;
}
};
private handleAdBlocked = () => {
console.log("[Matchmaking] Ad blocked detected");
this.adBlocked = true;
this.requestUpdate();
};
private waitForAdComplete = (): Promise<void> => {
// If ad is already complete or ads are disabled, resolve immediately
if (this.videoAdComplete || !window.adsEnabled) {
return Promise.resolve();
}
return new Promise((resolve) => {
this.adCompleteResolve = resolve;
});
};
private waitForAdMidpoint = (): Promise<void> => {
// If midpoint already reached or ads are disabled, resolve immediately
if (this.adMidpointReached || !window.adsEnabled) {
return Promise.resolve();
}
return new Promise((resolve) => {
this.adMidpointResolve = resolve;
});
};
private renderVideoAd() {
console.log(
"[Matchmaking] renderVideoAd, adsEnabled:",
window.adsEnabled,
"videoAdComplete:",
this.videoAdComplete,
"adBlocked:",
this.adBlocked,
);
if (!window.adsEnabled || this.videoAdComplete) {
return html``;
}
if (this.adBlocked) {
return html`
<div
class="w-full flex flex-col items-center justify-center mb-4 px-6 py-8 bg-red-900/30 border border-red-500/50 rounded-lg"
>
<p class="text-red-400 text-lg font-semibold text-center mb-2">
${translateText("matchmaking_modal.ad_blocked_title")}
</p>
<p class="text-white/70 text-sm text-center">
${translateText("matchmaking_modal.ad_blocked_message")}
</p>
</div>
`;
}
return html`
<div class="w-full flex justify-center mb-4 px-6">
<video-ad
.onComplete="${this.handleAdComplete}"
.onMidpoint="${this.handleAdMidpoint}"
.onAdBlocked="${this.handleAdBlocked}"
></video-ad>
</div>
`;
}
render() {
const eloDisplay = html`
<p class="text-center mt-2 mb-4 text-white/60">
<p class="text-center text-white/60">
${translateText("matchmaking_modal.elo", { elo: this.elo })}
</p>
`;
@@ -48,8 +134,8 @@ export class MatchmakingModal extends BaseModal {
onBack: this.close,
ariaLabel: translateText("common.back"),
})}
<div class="flex-1 flex flex-col items-center justify-center gap-6 p-6">
${eloDisplay} ${this.renderInner()}
<div class="flex-1 flex flex-col items-center gap-4 p-6 pt-2">
${eloDisplay} ${this.renderInner()} ${this.renderVideoAd()}
</div>
</div>
`;
@@ -71,6 +157,10 @@ export class MatchmakingModal extends BaseModal {
}
private renderInner() {
// Don't show spinner/status when ad is blocked
if (this.adBlocked) {
return html``;
}
if (!this.connected) {
return html`
<div class="flex flex-col items-center gap-4">
@@ -125,6 +215,17 @@ export class MatchmakingModal extends BaseModal {
// otherwise the "searching" message will be shown immediately.
// Also wait so people who back out immediately aren't added
// to the matchmaking queue.
// Wait for ad midpoint before sending join request
// This is so the ad doesn't get delay game start too long.
await this.waitForAdMidpoint();
// Early return if modal was closed while waiting for ad
if (!this.isModalOpen) {
this.socket?.close();
return;
}
this.socket.send(
JSON.stringify({
type: "join",
@@ -154,6 +255,11 @@ export class MatchmakingModal extends BaseModal {
}
protected async onOpen(): Promise<void> {
// Reset video ad state for each new matchmaking session
this.videoAdComplete = false;
this.adMidpointReached = false;
this.adBlocked = false;
const userMe = await getUserMe();
// Early return if modal was closed during async operation
if (!this.isModalOpen) {
@@ -232,6 +338,7 @@ export class MatchmakingModal extends BaseModal {
detail: {
gameID: this.gameID,
clientID: generateID(),
waitBeforeJoin: this.waitForAdComplete(),
} as JoinLobbyEvent,
bubbles: true,
composed: true,
+187
View File
@@ -0,0 +1,187 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
console.log("[VideoAd] Module loaded");
const VIDEO_AD_UNIT_TYPE = "precontent_ad_video";
@customElement("video-ad")
export class VideoAd extends LitElement {
@state()
private isVisible: boolean = true;
@property({ attribute: false })
onComplete?: () => void;
@property({ attribute: false })
onMidpoint?: () => void;
@property({ attribute: false })
onAdBlocked?: () => void;
private adLoadTimeout: ReturnType<typeof setTimeout> | null = null;
private adStarted = false;
// How long to wait for ad to start before assuming it's blocked
private static readonly AD_LOAD_TIMEOUT_MS = 8000;
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
console.log("[VideoAd] connectedCallback");
// Set dimensions on the custom element itself (required by Playwire)
// Playwire requires explicit pixel dimensions, use max-width for responsiveness
this.style.display = "block";
this.style.width = "100%";
this.style.maxWidth = "800px";
this.style.aspectRatio = "16/9";
this.showVideoAd();
}
disconnectedCallback() {
super.disconnectedCallback();
// Clean up timeout if component is removed
if (this.adLoadTimeout) {
clearTimeout(this.adLoadTimeout);
this.adLoadTimeout = null;
}
}
public showVideoAd(): void {
if (!window.ramp) {
// Wait for ramp to be available
const checkRamp = setInterval(() => {
if (window.ramp && window.ramp.que) {
clearInterval(checkRamp);
this.loadVideoAd();
}
}, 100);
return;
}
this.loadVideoAd();
}
private loadVideoAd(): void {
// Start timeout to detect if ad doesn't load (e.g., due to adblocker)
this.adLoadTimeout = setTimeout(() => {
if (!this.adStarted) {
console.log("[VideoAd] Ad load timeout - possible adblocker detected");
this.handleAdBlocked();
}
}, VideoAd.AD_LOAD_TIMEOUT_MS);
// Set up event listeners when player is ready
window.ramp.onPlayerReady = () => {
if (window.Bolt) {
// Listen for ad start to know ad is loading successfully
window.Bolt.on(
VIDEO_AD_UNIT_TYPE,
window.Bolt.BOLT_AD_STARTED ?? "boltAdStarted",
() => {
console.log("[VideoAd] Ad started");
this.adStarted = true;
// Clear the timeout since ad is playing
if (this.adLoadTimeout) {
clearTimeout(this.adLoadTimeout);
this.adLoadTimeout = null;
}
},
);
window.Bolt.on(VIDEO_AD_UNIT_TYPE, window.Bolt.BOLT_AD_COMPLETE, () => {
console.log("[VideoAd] Ad completed");
this.hideElement();
});
window.Bolt.on(VIDEO_AD_UNIT_TYPE, window.Bolt.BOLT_AD_ERROR, () => {
console.log("[VideoAd] Ad error/no fill");
this.handleAdBlocked();
});
window.Bolt.on(VIDEO_AD_UNIT_TYPE, window.Bolt.BOLT_MIDPOINT, () => {
console.log("[VideoAd] Ad midpoint");
if (this.onMidpoint) {
this.onMidpoint();
}
});
window.Bolt.on(
VIDEO_AD_UNIT_TYPE,
window.Bolt.SHOW_HIDDEN_CONTAINER ?? "showHiddenContainer",
() => {
console.log("[VideoAd] Ad finished");
this.hideElement();
},
);
}
};
// Queue the video ad initialization
window.ramp.que.push(() => {
const pwUnits = [{ type: VIDEO_AD_UNIT_TYPE }];
window.ramp
.addUnits(pwUnits)
.then(() => {
window.ramp.displayUnits();
})
.catch((e: Error) => {
console.error("[VideoAd] Error adding units:", e);
window.ramp.displayUnits();
});
});
}
private handleAdBlocked(): void {
// Clear timeout if still pending
if (this.adLoadTimeout) {
clearTimeout(this.adLoadTimeout);
this.adLoadTimeout = null;
}
// Call the callback if provided
if (this.onAdBlocked) {
this.onAdBlocked();
}
}
private hideElement(): void {
this.style.display = "none";
this.isVisible = false;
// Call the callback if provided
if (this.onComplete) {
this.onComplete();
}
// Also dispatch event for backwards compatibility
this.dispatchEvent(
new CustomEvent("ad-complete", {
bubbles: true,
composed: true,
}),
);
}
render() {
if (!this.isVisible) {
return html``;
}
// Provide a container for the Playwire video player to render into
// Structure matches Playwire example: wrapper > game-video-ad > precontent-video-location
return html`
<div
class="game-video-ad"
style="width: 100%; height: 100%; overflow: hidden;"
>
<div
id="precontent-video-location"
style="width: 100%; height: 100%;"
></div>
</div>
`;
}
}