diff --git a/resources/lang/en.json b/resources/lang/en.json index ef196e881..be110f9d2 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -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", diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index bd377d35d..da593a0f0 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -69,6 +69,7 @@ export function joinLobby( lobbyConfig: LobbyConfig, onPrestart: () => void, onJoin: () => void, + waitBeforeJoin: Promise = 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 | 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; diff --git a/src/client/Main.ts b/src/client/Main.ts index 2cfe82ad3..3d0452e4b 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -178,6 +178,24 @@ declare global { slots?: any; }; spaNewPage: (url?: string) => void; + // Video ad methods + onPlayerReady: (() => void) | null; + addUnits: (units: Array<{ type: string }>) => Promise; + 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; } class Client { @@ -850,6 +870,7 @@ class Client { // Store current URL for popstate confirmation this.currentUrl = window.location.href; }, + lobby.waitBeforeJoin, ); } diff --git a/src/client/Matchmaking.ts b/src/client/Matchmaking.ts index c29931896..d4458e6e0 100644 --- a/src/client/Matchmaking.ts +++ b/src/client/Matchmaking.ts @@ -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 => { + // 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 => { + // 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` +
+

+ ${translateText("matchmaking_modal.ad_blocked_title")} +

+

+ ${translateText("matchmaking_modal.ad_blocked_message")} +

+
+ `; + } + return html` +
+ +
+ `; + } + render() { const eloDisplay = html` -

+

${translateText("matchmaking_modal.elo", { elo: this.elo })}

`; @@ -48,8 +134,8 @@ export class MatchmakingModal extends BaseModal { onBack: this.close, ariaLabel: translateText("common.back"), })} -
- ${eloDisplay} ${this.renderInner()} +
+ ${eloDisplay} ${this.renderInner()} ${this.renderVideoAd()}
`; @@ -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`
@@ -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 { + // 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, diff --git a/src/client/components/VideoAd.ts b/src/client/components/VideoAd.ts new file mode 100644 index 000000000..d88ae1b75 --- /dev/null +++ b/src/client/components/VideoAd.ts @@ -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 | 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` +
+
+
+ `; + } +}