diff --git a/resources/images/promo/trophy.png b/resources/images/promo/trophy.png new file mode 100644 index 0000000000..6cd46c1d1f Binary files /dev/null and b/resources/images/promo/trophy.png differ diff --git a/resources/lobby-card-overlays.json b/resources/lobby-card-overlays.json new file mode 100644 index 0000000000..83b13aa615 --- /dev/null +++ b/resources/lobby-card-overlays.json @@ -0,0 +1,40 @@ +[ + { + "slot": 2, + "offset": 0, + "interval": 1, + "ttl": 15000, + "video": { + "url": "/video/promo/ofm_card_intro.mp4", + "videoLength": 6 + }, + "image": { + "url": "/images/promo/trophy.png" + }, + "displayInfo": { + "title": "Join the new OpenFront Masters tournament!", + "subtitle": "Sign up now", + "count": 40 + }, + "linkTo": "https://discord.gg/u5TnUxf43x" + }, + { + "slot": 1, + "offset": 1, + "interval": 2, + "ttl": 15000, + "video": { + "url": "/video/promo/ofm_card_intro.mp4", + "videoLength": 6 + }, + "image": { + "url": "/images/promo/trophy.png" + }, + "displayInfo": { + "title": "Join the new OpenFront Masters tournament!", + "subtitle": "Sign up now", + "count": 40 + }, + "linkTo": "https://discord.gg/u5TnUxf43x" + } +] diff --git a/resources/video/promo/ofm_card_intro.mp4 b/resources/video/promo/ofm_card_intro.mp4 new file mode 100644 index 0000000000..02dd664bef Binary files /dev/null and b/resources/video/promo/ofm_card_intro.mp4 differ diff --git a/src/client/Api.ts b/src/client/Api.ts index b3eecec4e8..5cd9142935 100644 --- a/src/client/Api.ts +++ b/src/client/Api.ts @@ -1,7 +1,9 @@ +import lobbyCardOverlaysFallback from "resources/lobby-card-overlays.json"; import newsItemsFallback from "resources/news.json"; import { z } from "zod"; -import type { NewsItem } from "../core/ApiSchemas"; +import type { LobbyCardOverlay, NewsItem } from "../core/ApiSchemas"; import { + LobbyCardOverlaySchema, ClaimAllRewardsResponse, ClaimAllRewardsResponseSchema, ClaimRewardResponse, @@ -683,3 +685,25 @@ export async function getNews(): Promise { return newsItemsFallback as NewsItem[]; } } + +export async function getLobbyCardOverlays(): Promise { + try { + const res = await fetch(`${getApiBase()}/lobby-card-overlays.json`, { + headers: { Accept: "application/json" }, + }); + if (res.status !== 200) { + console.warn("getLobbyCardOverlays: unexpected status", res.status); + return lobbyCardOverlaysFallback as LobbyCardOverlay[]; + } + const json = await res.json(); + const parsed = z.array(LobbyCardOverlaySchema).safeParse(json); + if (!parsed.success) { + console.warn("getLobbyCardOverlays: Zod validation failed", parsed.error); + return lobbyCardOverlaysFallback as LobbyCardOverlay[]; + } + return parsed.data; + } catch (err) { + console.warn("getLobbyCardOverlays: request failed, using fallback", err); + return lobbyCardOverlaysFallback as LobbyCardOverlay[]; + } +} diff --git a/src/client/GameModeSelector.ts b/src/client/GameModeSelector.ts index 56b2b2528f..945c9e09ae 100644 --- a/src/client/GameModeSelector.ts +++ b/src/client/GameModeSelector.ts @@ -1,6 +1,7 @@ import { html, LitElement, nothing, type TemplateResult } from "lit"; import { customElement, state } from "lit/decorators.js"; import { ClientEnv } from "src/client/ClientEnv"; +import type { LobbyCardOverlay } from "../core/ApiSchemas"; import { Duos, GameMapType, @@ -10,6 +11,7 @@ import { Trios, } from "../core/game/Game"; import { PublicGameInfo, PublicGames } from "../core/Schemas"; +import { getLobbyCardOverlays } from "./Api"; import "./components/IOSAddToHomeScreenBanner"; import { HostLobbyModal } from "./HostLobbyModal"; import { JoinLobbyModal } from "./JoinLobbyModal"; @@ -29,6 +31,21 @@ import { const CARD_BG = "bg-surface"; +const OVERLAY_SLOT_KEYS = ["ffa", "special", "team"] as const; +const SLOT_FFA = 0; +const SLOT_SPECIAL = 1; +const SLOT_TEAM = 2; + +const OVERLAY_FADE_MS = 300; + +type OverlayPhase = "entering" | "video" | "fading" | "card"; + +interface ActiveOverlay { + slot: number; + overlay: LobbyCardOverlay; + phase: OverlayPhase; +} + @customElement("game-mode-selector") export class GameModeSelector extends LitElement { @state() private lobbies: PublicGames | null = null; @@ -37,6 +54,17 @@ export class GameModeSelector extends LitElement { private serverTimeOffset: number = 0; private defaultLobbyTime: number = 0; + @state() private overlays: LobbyCardOverlay[] = []; + @state() private activeOverlays: Map = new Map(); + private overlaySlotState: Map< + number, + { lastGameId?: string; count: number } + > = new Map(); + private overlayLastHandledCount = new WeakMap(); + private overlayTimers: Map> = new Map(); + private overlayDismissTimers: Map> = + new Map(); + private lobbySocket = new PublicLobbySocket((lobbies) => this.handleLobbiesUpdate(lobbies), ); @@ -68,6 +96,11 @@ export class GameModeSelector extends LitElement { if (usernameInput) { this.inputValid = usernameInput.canPlay(); } + getLobbyCardOverlays() + .then((overlays) => { + this.overlays = overlays; + }) + .catch((e) => console.error("Failed to load lobby card overlays", e)); } disconnectedCallback() { @@ -76,6 +109,14 @@ export class GameModeSelector extends LitElement { "username-validity-change", this.handleValidityChange, ); + for (const timer of this.overlayTimers.values()) { + clearTimeout(timer); + } + this.overlayTimers.clear(); + for (const timer of this.overlayDismissTimers.values()) { + clearTimeout(timer); + } + this.overlayDismissTimers.clear(); super.disconnectedCallback(); } @@ -101,11 +142,9 @@ export class GameModeSelector extends LitElement { for (const game of allGames) { const mapType = game.gameConfig?.gameMap as GameMapType; if (mapType && !this.mapAspectRatios.has(mapType)) { - // New Map reference triggers Lit reactivity; placeholder ratio 1 lets - // has() guard against duplicate in-flight fetches. this.mapAspectRatios = new Map(this.mapAspectRatios).set(mapType, 1); - terrainMapFileLoader - .getMapData(mapType) + const mapData = terrainMapFileLoader.getMapData(mapType); + mapData .manifest() .then((m: any) => { if (m?.map?.width && m?.map?.height) { @@ -118,8 +157,131 @@ export class GameModeSelector extends LitElement { .catch((e) => console.error(`Failed to load manifest for ${mapType}`, e), ); + new Image().src = mapData.webpPath; } } + + this.checkOverlayTriggers(); + } + + private checkOverlayTriggers() { + const slotsSeen = new Set(); + for (const overlay of this.overlays) { + if (slotsSeen.has(overlay.slot)) continue; + slotsSeen.add(overlay.slot); + + const slotKey = OVERLAY_SLOT_KEYS[overlay.slot]; + const lobby = slotKey ? this.lobbies?.games?.[slotKey]?.[0] : undefined; + if (!lobby) continue; + + const slotState = this.overlaySlotState.get(overlay.slot) ?? { + count: 0, + }; + if (lobby.gameID === slotState.lastGameId) continue; + + slotState.lastGameId = lobby.gameID; + slotState.count += 1; + this.overlaySlotState.set(overlay.slot, slotState); + } + + for (const overlay of this.overlays) { + if (overlay.interval <= 0) continue; + const slotState = this.overlaySlotState.get(overlay.slot); + if (!slotState) continue; + if (this.overlayLastHandledCount.get(overlay) === slotState.count) { + continue; + } + this.overlayLastHandledCount.set(overlay, slotState.count); + + if (this.activeOverlays.has(overlay.slot)) continue; + if ((slotState.count + overlay.offset) % overlay.interval === 0) { + this.triggerOverlay(overlay); + } + } + } + + private setOverlayTimer(slot: number, ms: number, fn: () => void) { + const existing = this.overlayTimers.get(slot); + if (existing) clearTimeout(existing); + this.overlayTimers.set(slot, setTimeout(fn, ms)); + } + + private clearOverlayTimer(slot: number) { + const existing = this.overlayTimers.get(slot); + if (existing) { + clearTimeout(existing); + this.overlayTimers.delete(slot); + } + } + + private setActiveOverlay(slot: number, active: ActiveOverlay | null) { + const next = new Map(this.activeOverlays); + if (active) { + next.set(slot, active); + } else { + next.delete(slot); + } + this.activeOverlays = next; + } + + private triggerOverlay(overlay: LobbyCardOverlay) { + const slot = overlay.slot; + this.setActiveOverlay(slot, { slot, overlay, phase: "entering" }); + setTimeout(() => this.handleVideoReady(slot), 800); + this.setOverlayTimer(slot, (overlay.video.videoLength + 2) * 1000, () => + this.advanceOverlayToFading(slot), + ); + const existingDismiss = this.overlayDismissTimers.get(slot); + if (existingDismiss) clearTimeout(existingDismiss); + this.overlayDismissTimers.set( + slot, + setTimeout(() => this.dismissOverlay(slot), overlay.ttl), + ); + } + + private handleVideoReady(slot: number) { + const active = this.activeOverlays.get(slot); + if (active?.phase === "entering") { + this.setActiveOverlay(slot, { ...active, phase: "video" }); + } + } + + private handleVideoTimeUpdate(slot: number, e: Event) { + const video = e.currentTarget as HTMLVideoElement; + if (!isFinite(video.duration)) return; + if (video.duration - video.currentTime <= OVERLAY_FADE_MS / 1000) { + this.advanceOverlayToFading(slot); + } + } + + private advanceOverlayToFading(slot: number) { + const active = this.activeOverlays.get(slot); + if (!active || (active.phase !== "video" && active.phase !== "entering")) { + return; + } + this.setActiveOverlay(slot, { ...active, phase: "fading" }); + this.setOverlayTimer(slot, OVERLAY_FADE_MS, () => + this.advanceOverlayToCard(slot), + ); + } + + private advanceOverlayToCard(slot: number) { + const active = this.activeOverlays.get(slot); + if (!active || active.phase !== "fading") return; + // No dismiss timer scheduled here: the total-ttl timer from + // triggerOverlay is already pending and covers this phase too. + this.setActiveOverlay(slot, { ...active, phase: "card" }); + } + + private dismissOverlay(slot: number) { + if (!this.activeOverlays.has(slot)) return; + this.clearOverlayTimer(slot); + const dismissTimer = this.overlayDismissTimers.get(slot); + if (dismissTimer) { + clearTimeout(dismissTimer); + this.overlayDismissTimers.delete(slot); + } + this.setActiveOverlay(slot, null); } render() { @@ -176,7 +338,7 @@ export class GameModeSelector extends LitElement { ${ffa ? html`` : nothing} @@ -184,29 +346,25 @@ export class GameModeSelector extends LitElement {
- ${special ? this.renderSpecialLobbyCard(special) : nothing} + ${special ? this.renderCard(SLOT_SPECIAL, special) : nothing}
- ${ffa - ? this.renderLobbyCard(ffa, this.getLobbyTitle(ffa)) - : nothing} + ${ffa ? this.renderCard(SLOT_FFA, ffa) : nothing}
- ${teams - ? this.renderLobbyCard(teams, this.getLobbyTitle(teams)) - : nothing} + ${teams ? this.renderCard(SLOT_TEAM, teams) : nothing}
`} @@ -241,7 +399,11 @@ export class GameModeSelector extends LitElement { `; } - private renderSpecialLobbyCard(lobby: PublicGameInfo) { + private renderCard(slot: number, lobby: PublicGameInfo) { + const active = this.activeOverlays.get(slot); + if (active) { + return this.renderOverlayCard(active); + } return this.renderLobbyCard(lobby, this.getLobbyTitle(lobby)); } @@ -418,6 +580,149 @@ export class GameModeSelector extends LitElement { `; } + private renderOverlayCard(active: ActiveOverlay) { + const { overlay, phase, slot } = active; + const showVideo = phase !== "card"; + const showReveal = phase === "fading" || phase === "card"; + + return html` +
+ +
+ ${showReveal + ? html` +

+ ${overlay.displayInfo.title} +

+ ${overlay.image.url + ? html`` + : nothing} + ` + : nothing} +
+
+ ${overlay.displayInfo.count !== undefined + ? html` + ${overlay.displayInfo.count} + + + + + + + + + + ` + : nothing} +

+ ${overlay.displayInfo.subtitle} + + + +

+
+
+ + ${showVideo + ? html`` + : nothing} +
+ `; + } + private validateAndJoin(lobby: PublicGameInfo) { if (!this.validateUsername()) return; diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index 0eeb2ee6fd..db2a83b1f4 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -349,3 +349,24 @@ export const NewsItemSchema = z.object({ type: z.enum(["tournament", "tutorial", "announcement"]).or(z.string()), }); export type NewsItem = z.infer; + +export const LobbyCardOverlaySchema = z.object({ + slot: z.number(), + interval: z.number(), + offset: z.number().default(0), + ttl: z.number(), + video: z.object({ + url: z.string(), + videoLength: z.number(), + }), + image: z.object({ + url: z.string(), + }), + displayInfo: z.object({ + title: z.string(), + subtitle: z.string(), + count: z.number().optional(), + }), + linkTo: z.url(), +}); +export type LobbyCardOverlay = z.infer;