mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 12:10:46 +00:00
Rewarded videos ads to test a skin (#3120)
## Description: Added rewarded video ads for skin trials via Playwire's manuallyCreateRewardUi API. Users can now click "Try me" to watch a video ad and receive a temporary skin trial. Upon completion a temporary flare is granted to the player so they have ~5 minutes to use the skin. added getPlayerCosmeticsRefs and getPlayerCosmetics to Cosmetics.ts to centralize cosmetic retrieval & validation. <img width="801" height="534" alt="Screenshot 2026-02-10 at 7 58 14 PM" src="https://github.com/user-attachments/assets/51cc378c-2feb-4692-8cf2-20ee54cea3b8" /> ## 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: evan
This commit is contained in:
@@ -868,13 +868,15 @@
|
||||
"show_only_owned": "My Skins",
|
||||
"all_owned": "All skins owned! Check back later for new items.",
|
||||
"not_logged_in": "Not logged in",
|
||||
"blocked": {
|
||||
"login": "You must be logged in to access this skin.",
|
||||
"purchase": "Purchase this skin to unlock it."
|
||||
},
|
||||
"pattern": {
|
||||
"default": "Default"
|
||||
},
|
||||
"try_me": "Try me!",
|
||||
"trial_remaining": "remaining",
|
||||
"trial_granted": "Skin trial granted!",
|
||||
"trial_cooldown": "Only one trial per 24 hours. Please try again later.",
|
||||
"reward_countdown": "Reward in {seconds} seconds...",
|
||||
"steam_wishlist_prompt": "Support OpenFront by adding it to your Steam wishlist",
|
||||
"select_skin": "Select Skin",
|
||||
"selected": "selected"
|
||||
},
|
||||
|
||||
@@ -125,6 +125,31 @@ export async function createCheckoutSession(
|
||||
}
|
||||
}
|
||||
|
||||
export async function grantTemporaryFlare(flare: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${getApiBase()}/flares_granted/temporary`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: await getAuthHeader(),
|
||||
},
|
||||
body: JSON.stringify({ flare }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
"grantTemporaryFlare: request failed",
|
||||
response.status,
|
||||
response.statusText,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("grantTemporaryFlare: request failed", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getApiBase() {
|
||||
const domainname = getAudience();
|
||||
|
||||
|
||||
+98
-3
@@ -5,7 +5,15 @@ import {
|
||||
CosmeticsSchema,
|
||||
Pattern,
|
||||
} from "../core/CosmeticSchemas";
|
||||
import { createCheckoutSession, getApiBase } from "./Api";
|
||||
import {
|
||||
PlayerCosmeticRefs,
|
||||
PlayerCosmetics,
|
||||
PlayerPattern,
|
||||
} from "../core/Schemas";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import { createCheckoutSession, getApiBase, getUserMe } from "./Api";
|
||||
|
||||
export const TEMP_FLARE_OFFSET = 1 * 60 * 1000; // 1 minute
|
||||
|
||||
export async function handlePurchase(
|
||||
pattern: Pattern,
|
||||
@@ -77,14 +85,19 @@ export async function getCosmeticsHash(): Promise<string | null> {
|
||||
return __cosmeticsHash;
|
||||
}
|
||||
|
||||
// When a number is returned it signifies when the pattern expires.
|
||||
export function patternRelationship(
|
||||
pattern: Pattern,
|
||||
colorPalette: { name: string; isArchived?: boolean } | null,
|
||||
userMeResponse: UserMeResponse | false,
|
||||
affiliateCode: string | null,
|
||||
): "owned" | "purchasable" | "blocked" {
|
||||
): "owned" | "purchasable" | "purchasable_no_trial" | "blocked" | number {
|
||||
const flares =
|
||||
userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
|
||||
const expirations: Record<string, number> =
|
||||
userMeResponse === false
|
||||
? {}
|
||||
: (userMeResponse.player.flareExpiration ?? {});
|
||||
if (flares.includes("pattern:*")) {
|
||||
return "owned";
|
||||
}
|
||||
@@ -100,6 +113,14 @@ export function patternRelationship(
|
||||
const requiredFlare = `pattern:${pattern.name}:${colorPalette.name}`;
|
||||
|
||||
if (flares.includes(requiredFlare)) {
|
||||
const expiresAt = expirations[requiredFlare];
|
||||
if (expiresAt) {
|
||||
if (expiresAt - Date.now() <= TEMP_FLARE_OFFSET) {
|
||||
// Already expired or about to expire so just show it as purchasable.
|
||||
return "purchasable";
|
||||
}
|
||||
return expiresAt;
|
||||
}
|
||||
return "owned";
|
||||
}
|
||||
|
||||
@@ -118,6 +139,80 @@ export function patternRelationship(
|
||||
return "blocked";
|
||||
}
|
||||
|
||||
// Patterns is for sale, and it's the right store to show it on.
|
||||
// --- Patterns is for sale, and it's the right store to show it on. ---
|
||||
|
||||
if (pattern.name === "custom") {
|
||||
// Don't allow trying a custom pattern.
|
||||
return "purchasable_no_trial";
|
||||
}
|
||||
return "purchasable";
|
||||
}
|
||||
|
||||
export async function getPlayerCosmeticsRefs(): Promise<PlayerCosmeticRefs> {
|
||||
const userSettings = new UserSettings();
|
||||
const cosmetics = await fetchCosmetics();
|
||||
let pattern: PlayerPattern | null =
|
||||
userSettings.getSelectedPatternName(cosmetics);
|
||||
|
||||
if (pattern) {
|
||||
const userMe = await getUserMe();
|
||||
if (userMe) {
|
||||
const flareName =
|
||||
pattern.colorPalette?.name === undefined
|
||||
? `pattern:${pattern.name}`
|
||||
: `pattern:${pattern.name}:${pattern.colorPalette.name}`;
|
||||
const flares = userMe.player.flares ?? [];
|
||||
const expirations = userMe.player.flareExpiration ?? {};
|
||||
const hasWildcard = flares.includes("pattern:*");
|
||||
if (!hasWildcard) {
|
||||
if (!flares.includes(flareName)) {
|
||||
pattern = null;
|
||||
} else if (expirations[flareName]) {
|
||||
if (expirations[flareName]! - Date.now() <= TEMP_FLARE_OFFSET) {
|
||||
pattern = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pattern === null) {
|
||||
userSettings.setSelectedPatternName(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
flag: userSettings.getFlag(),
|
||||
color: userSettings.getSelectedColor() ?? undefined,
|
||||
patternName: pattern?.name ?? undefined,
|
||||
patternColorPaletteName: pattern?.colorPalette?.name ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPlayerCosmetics(): Promise<PlayerCosmetics> {
|
||||
const refs = await getPlayerCosmeticsRefs();
|
||||
const cosmetics = await fetchCosmetics();
|
||||
|
||||
const result: PlayerCosmetics = {};
|
||||
|
||||
if (refs.flag) {
|
||||
result.flag = refs.flag;
|
||||
}
|
||||
|
||||
if (refs.color) {
|
||||
result.color = { color: refs.color };
|
||||
}
|
||||
|
||||
if (refs.patternName && cosmetics) {
|
||||
const pattern = cosmetics.patterns[refs.patternName];
|
||||
if (pattern) {
|
||||
result.pattern = {
|
||||
name: refs.patternName,
|
||||
patternData: pattern.pattern,
|
||||
colorPalette: refs.patternColorPaletteName
|
||||
? cosmetics.colorPalettes?.[refs.patternColorPaletteName]
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
+8
-14
@@ -10,7 +10,7 @@ import "./AccountModal";
|
||||
import { getUserMe } from "./Api";
|
||||
import { userAuth } from "./Auth";
|
||||
import { joinLobby } from "./ClientGameRunner";
|
||||
import { fetchCosmetics } from "./Cosmetics";
|
||||
import { getPlayerCosmeticsRefs } from "./Cosmetics";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
import "./FlagInput";
|
||||
import { FlagInput } from "./FlagInput";
|
||||
@@ -182,6 +182,12 @@ declare global {
|
||||
onPlayerReady: (() => void) | null;
|
||||
addUnits: (units: Array<{ type: string }>) => Promise<void>;
|
||||
displayUnits: () => void;
|
||||
// Rewarded video ad methods
|
||||
manuallyCreateRewardUi?: (options: {
|
||||
skipConfirmation?: boolean;
|
||||
watchAdId?: string;
|
||||
closeId?: string;
|
||||
}) => Promise<void> | void;
|
||||
};
|
||||
Bolt: {
|
||||
on: (unitType: string, event: string, callback: () => void) => void;
|
||||
@@ -761,24 +767,12 @@ class Client {
|
||||
const config = await getServerConfigFromClient();
|
||||
this.updateJoinUrlForShare(lobby.gameID, config);
|
||||
|
||||
const pattern = this.userSettings.getSelectedPatternName(
|
||||
await fetchCosmetics(),
|
||||
);
|
||||
|
||||
this.gameStop = joinLobby(
|
||||
this.eventBus,
|
||||
{
|
||||
gameID: lobby.gameID,
|
||||
serverConfig: config,
|
||||
cosmetics: {
|
||||
color: this.userSettings.getSelectedColor() ?? undefined,
|
||||
patternName: pattern?.name ?? undefined,
|
||||
patternColorPaletteName: pattern?.colorPalette?.name ?? undefined,
|
||||
flag:
|
||||
this.flagInput === null || this.flagInput.getCurrentFlag() === "xx"
|
||||
? ""
|
||||
: this.flagInput.getCurrentFlag(),
|
||||
},
|
||||
cosmetics: await getPlayerCosmeticsRefs(),
|
||||
turnstileToken: await this.getTurnstileToken(lobby),
|
||||
playerName:
|
||||
this.usernameInput?.getCurrentUsername() ?? genAnonUsername(),
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { Cosmetics } from "../core/CosmeticSchemas";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import { PlayerPattern } from "../core/Schemas";
|
||||
import { renderPatternPreview } from "./components/PatternButton";
|
||||
import { fetchCosmetics } from "./Cosmetics";
|
||||
import { getPlayerCosmetics } from "./Cosmetics";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
@@ -17,24 +15,14 @@ export class PatternInput extends LitElement {
|
||||
@property({ type: Boolean, attribute: "show-select-label" })
|
||||
public showSelectLabel: boolean = false;
|
||||
|
||||
private userSettings = new UserSettings();
|
||||
private cosmetics: Cosmetics | null = null;
|
||||
private _abortController: AbortController | null = null;
|
||||
|
||||
private _onPatternSelected = () => {
|
||||
this.updateFromSettings();
|
||||
private _onPatternSelected = async () => {
|
||||
const cosmetics = await getPlayerCosmetics();
|
||||
this.selectedColor = cosmetics.color?.color ?? null;
|
||||
this.pattern = cosmetics.pattern ?? null;
|
||||
};
|
||||
|
||||
private updateFromSettings() {
|
||||
this.selectedColor = this.userSettings.getSelectedColor() ?? null;
|
||||
|
||||
if (this.cosmetics) {
|
||||
this.pattern = this.userSettings.getSelectedPatternName(this.cosmetics);
|
||||
} else {
|
||||
this.pattern = null;
|
||||
}
|
||||
}
|
||||
|
||||
private onInputClick(e: Event) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -50,10 +38,9 @@ export class PatternInput extends LitElement {
|
||||
super.connectedCallback();
|
||||
this._abortController = new AbortController();
|
||||
this.isLoading = true;
|
||||
const cosmetics = await fetchCosmetics();
|
||||
if (!this.isConnected) return;
|
||||
this.cosmetics = cosmetics;
|
||||
this.updateFromSettings();
|
||||
const cosmetics = await getPlayerCosmetics();
|
||||
this.selectedColor = cosmetics.color?.color ?? null;
|
||||
this.pattern = cosmetics.pattern ?? null;
|
||||
if (!this.isConnected) return;
|
||||
this.isLoading = false;
|
||||
window.addEventListener("pattern-selected", this._onPatternSelected, {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
let rewardedUnitRegistered = false;
|
||||
let rewardedAdReady = false;
|
||||
|
||||
// Listen for when rewarded ad becomes available
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("rewardedAdVideoRewardReady", () => {
|
||||
console.log("[RewardedVideoPromo] Rewarded ad is ready");
|
||||
rewardedAdReady = true;
|
||||
});
|
||||
}
|
||||
|
||||
const AD_READY_TIMEOUT_MS = 3000;
|
||||
|
||||
function ensureRewardedUnitRegistered(): Promise<void> {
|
||||
console.log("[ensureRewardedUnitRegistered] Called", {
|
||||
rewardedUnitRegistered,
|
||||
rewardedAdReady,
|
||||
hasSpaAddAds: !!window.ramp?.spaAddAds,
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Check for real SDK (not just stub from index.html)
|
||||
if (!window.ramp?.spaAddAds) {
|
||||
console.log(
|
||||
"[ensureRewardedUnitRegistered] Rejecting: spaAddAds not available",
|
||||
);
|
||||
reject(new Error("Ramp SDK not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
// If already registered and ready, resolve immediately
|
||||
if (rewardedUnitRegistered && rewardedAdReady) {
|
||||
console.log(
|
||||
"[ensureRewardedUnitRegistered] Already registered and ready",
|
||||
);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Register the unit if not already registered
|
||||
if (!rewardedUnitRegistered) {
|
||||
try {
|
||||
window.ramp.spaAddAds([{ type: "rewarded_ad_video", selectorId: "" }]);
|
||||
rewardedUnitRegistered = true;
|
||||
console.log("[RewardedVideoPromo] Rewarded unit registered");
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If ad is already ready, resolve
|
||||
if (rewardedAdReady) {
|
||||
console.log("[ensureRewardedUnitRegistered] Ad already ready");
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for the rewardedAdVideoRewardReady event or no-fill event
|
||||
console.log("[ensureRewardedUnitRegistered] Waiting for ad to be ready...");
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
window.removeEventListener("rewardedAdVideoRewardReady", onReady);
|
||||
window.removeEventListener("rewardedVideoNoFill", onNoFill);
|
||||
window.removeEventListener("rewardedAdNoFill", onNoFill);
|
||||
window.removeEventListener("pwNoFillEvent", onNoFill);
|
||||
};
|
||||
|
||||
const onReady = () => {
|
||||
console.log("[ensureRewardedUnitRegistered] Ad is now ready");
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
|
||||
const onNoFill = () => {
|
||||
console.log("[ensureRewardedUnitRegistered] No fill event received");
|
||||
cleanup();
|
||||
reject(new Error("No rewarded ad available"));
|
||||
};
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
cleanup();
|
||||
console.log("[ensureRewardedUnitRegistered] Timeout waiting for ad");
|
||||
reject(new Error("Ad timeout"));
|
||||
}, AD_READY_TIMEOUT_MS);
|
||||
|
||||
window.addEventListener("rewardedAdVideoRewardReady", onReady);
|
||||
window.addEventListener("rewardedVideoNoFill", onNoFill);
|
||||
window.addEventListener("rewardedAdNoFill", onNoFill);
|
||||
window.addEventListener("pwNoFillEvent", onNoFill);
|
||||
});
|
||||
}
|
||||
|
||||
export function showRewardedAd(): Promise<void> {
|
||||
console.log("[showRewardedAd] Called", {
|
||||
rewardedUnitRegistered,
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log("[showRewardedAd] Calling ensureRewardedUnitRegistered...");
|
||||
ensureRewardedUnitRegistered()
|
||||
.then(() => {
|
||||
console.log("[showRewardedAd] ensureRewardedUnitRegistered resolved");
|
||||
if (!window.ramp?.manuallyCreateRewardUi) {
|
||||
reject(new Error("Ramp SDK manuallyCreateRewardUi not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up event listeners before triggering the ad
|
||||
const cleanup = () => {
|
||||
window.removeEventListener(
|
||||
"rewardedAdRewardGranted",
|
||||
onRewardGranted,
|
||||
);
|
||||
window.removeEventListener("rewardedAdCompleted", onCompleted);
|
||||
window.removeEventListener("rewardedCloseButtonTriggered", onClosed);
|
||||
window.removeEventListener("rejectAdCloseCta", onRejected);
|
||||
// Destroy old unit and reset state so next ad attempt will re-register
|
||||
try {
|
||||
window.ramp?.destroyUnits?.("rewarded_ad_video");
|
||||
} catch (e) {
|
||||
console.error("[showRewardedAd] Failed to destroy unit:", e);
|
||||
}
|
||||
rewardedUnitRegistered = false;
|
||||
rewardedAdReady = false;
|
||||
};
|
||||
|
||||
const onRewardGranted = () => {
|
||||
console.log("[showRewardedAd] Reward granted");
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
|
||||
const onCompleted = () => {
|
||||
console.log("[showRewardedAd] Ad completed without reward");
|
||||
// Don't resolve here - wait for rewardedAdRewardGranted
|
||||
};
|
||||
|
||||
const onClosed = () => {
|
||||
console.log("[showRewardedAd] User closed ad early");
|
||||
cleanup();
|
||||
reject(new Error("User closed ad early"));
|
||||
};
|
||||
|
||||
const onRejected = () => {
|
||||
console.log("[showRewardedAd] User rejected ad");
|
||||
cleanup();
|
||||
reject(new Error("User rejected ad"));
|
||||
};
|
||||
|
||||
window.addEventListener("rewardedAdRewardGranted", onRewardGranted);
|
||||
window.addEventListener("rewardedAdCompleted", onCompleted);
|
||||
window.addEventListener("rewardedCloseButtonTriggered", onClosed);
|
||||
window.addEventListener("rejectAdCloseCta", onRejected);
|
||||
|
||||
// Trigger the ad
|
||||
const result = window.ramp.manuallyCreateRewardUi({
|
||||
skipConfirmation: true,
|
||||
});
|
||||
|
||||
// If it returns a promise that rejects, handle that too
|
||||
if (result && typeof result.then === "function") {
|
||||
result.catch((error: unknown) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(
|
||||
"[showRewardedAd] ensureRewardedUnitRegistered rejected:",
|
||||
err,
|
||||
);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
UnitType,
|
||||
mapCategories,
|
||||
} from "../core/game/Game";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import { TeamCountConfig } from "../core/Schemas";
|
||||
import { generateID } from "../core/Util";
|
||||
import { hasLinkedAccount } from "./Api";
|
||||
@@ -26,9 +25,8 @@ import "./components/Difficulties";
|
||||
import "./components/FluentSlider";
|
||||
import "./components/Maps";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import { fetchCosmetics } from "./Cosmetics";
|
||||
import { getPlayerCosmetics } from "./Cosmetics";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
import { FlagInput } from "./FlagInput";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
import { UsernameInput } from "./UsernameInput";
|
||||
import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions";
|
||||
@@ -60,8 +58,6 @@ export class SinglePlayerModal extends BaseModal {
|
||||
|
||||
@state() private disabledUnits: UnitType[] = [];
|
||||
|
||||
private userSettings: UserSettings = new UserSettings();
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
document.addEventListener(
|
||||
@@ -1049,18 +1045,6 @@ export class SinglePlayerModal extends BaseModal {
|
||||
console.warn("Username input element not found");
|
||||
}
|
||||
|
||||
const flagInput = document.querySelector("flag-input") as FlagInput;
|
||||
if (!flagInput) {
|
||||
console.warn("Flag input element not found");
|
||||
}
|
||||
const cosmetics = await fetchCosmetics();
|
||||
let selectedPattern = this.userSettings.getSelectedPatternName(cosmetics);
|
||||
selectedPattern ??= cosmetics
|
||||
? (this.userSettings.getDevOnlyPattern() ?? null)
|
||||
: null;
|
||||
|
||||
const selectedColor = this.userSettings.getSelectedColor();
|
||||
|
||||
await crazyGamesSDK.requestMidgameAd();
|
||||
|
||||
this.dispatchEvent(
|
||||
@@ -1074,14 +1058,7 @@ export class SinglePlayerModal extends BaseModal {
|
||||
{
|
||||
clientID,
|
||||
username: usernameInput.getCurrentUsername(),
|
||||
cosmetics: {
|
||||
flag:
|
||||
flagInput.getCurrentFlag() === "xx"
|
||||
? ""
|
||||
: flagInput.getCurrentFlag(),
|
||||
pattern: selectedPattern ?? undefined,
|
||||
color: selectedColor ? { color: selectedColor } : undefined,
|
||||
},
|
||||
cosmetics: await getPlayerCosmetics(),
|
||||
},
|
||||
],
|
||||
config: {
|
||||
|
||||
@@ -12,8 +12,10 @@ import "./components/PatternButton";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import {
|
||||
fetchCosmetics,
|
||||
getPlayerCosmetics,
|
||||
handlePurchase,
|
||||
patternRelationship,
|
||||
TEMP_FLARE_OFFSET,
|
||||
} from "./Cosmetics";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
@@ -37,8 +39,8 @@ export class TerritoryPatternsModal extends BaseModal {
|
||||
|
||||
private userMeResponse: UserMeResponse | false = false;
|
||||
|
||||
private _onPatternSelected = () => {
|
||||
this.updateFromSettings();
|
||||
private _onPatternSelected = async () => {
|
||||
await this.updateFromSettings();
|
||||
this.refresh();
|
||||
};
|
||||
|
||||
@@ -62,24 +64,16 @@ export class TerritoryPatternsModal extends BaseModal {
|
||||
window.removeEventListener("pattern-selected", this._onPatternSelected);
|
||||
}
|
||||
|
||||
private updateFromSettings() {
|
||||
this.selectedPattern =
|
||||
this.cosmetics !== null
|
||||
? this.userSettings.getSelectedPatternName(this.cosmetics)
|
||||
: null;
|
||||
this.selectedColor = this.userSettings.getSelectedColor() ?? null;
|
||||
private async updateFromSettings() {
|
||||
const cosmetics = await getPlayerCosmetics();
|
||||
this.selectedPattern = cosmetics.pattern ?? null;
|
||||
this.selectedColor = cosmetics.color?.color ?? null;
|
||||
}
|
||||
|
||||
async onUserMe(userMeResponse: UserMeResponse | false) {
|
||||
if (!hasLinkedAccount(userMeResponse)) {
|
||||
this.userSettings.setSelectedPatternName(undefined);
|
||||
this.userSettings.setSelectedColor(undefined);
|
||||
this.selectedPattern = null;
|
||||
this.selectedColor = null;
|
||||
}
|
||||
this.userMeResponse = userMeResponse;
|
||||
this.cosmetics = await fetchCosmetics();
|
||||
this.updateFromSettings();
|
||||
await this.updateFromSettings();
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
@@ -130,7 +124,7 @@ export class TerritoryPatternsModal extends BaseModal {
|
||||
? [...(pattern.colorPalettes ?? []), null]
|
||||
: [null];
|
||||
for (const colorPalette of colorPalettes) {
|
||||
let rel = "owned";
|
||||
let rel: string | number = "owned";
|
||||
if (pattern) {
|
||||
rel = patternRelationship(
|
||||
pattern,
|
||||
@@ -142,8 +136,9 @@ export class TerritoryPatternsModal extends BaseModal {
|
||||
if (rel === "blocked") {
|
||||
continue;
|
||||
}
|
||||
const isTrial = typeof rel === "number";
|
||||
if (this.showOnlyOwned) {
|
||||
if (rel !== "owned") continue;
|
||||
if (rel !== "owned" && !isTrial) continue;
|
||||
} else {
|
||||
// Store mode: hide owned items
|
||||
if (rel === "owned") continue;
|
||||
@@ -163,7 +158,19 @@ export class TerritoryPatternsModal extends BaseModal {
|
||||
.colorPalette=${this.cosmetics?.colorPalettes?.[
|
||||
colorPalette?.name ?? ""
|
||||
] ?? null}
|
||||
.requiresPurchase=${rel === "purchasable"}
|
||||
.requiresPurchase=${rel === "purchasable" ||
|
||||
rel === "purchasable_no_trial"}
|
||||
.allowTrial=${rel === "purchasable"}
|
||||
.trialCooldown=${this.userMeResponse !== false &&
|
||||
this.userMeResponse.player.tempFlaresCooldown}
|
||||
.trialTimeRemaining=${isTrial
|
||||
? Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
((rel as number) - TEMP_FLARE_OFFSET - Date.now()) / 1000,
|
||||
),
|
||||
)
|
||||
: 0}
|
||||
.selected=${isSelected}
|
||||
.onSelect=${(p: PlayerPattern | null) => this.selectPattern(p)}
|
||||
.onPurchase=${(p: Pattern, colorPalette: ColorPalette | null) =>
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { Colord } from "colord";
|
||||
import { base64url } from "jose";
|
||||
import { html, LitElement, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import {
|
||||
ColorPalette,
|
||||
DefaultPattern,
|
||||
Pattern,
|
||||
} from "../../core/CosmeticSchemas";
|
||||
import { UserSettings } from "../../core/game/UserSettings";
|
||||
import { PatternDecoder } from "../../core/PatternDecoder";
|
||||
import { PlayerPattern } from "../../core/Schemas";
|
||||
import { grantTemporaryFlare } from "../Api";
|
||||
import { showRewardedAd } from "../RewardedVideoPromo";
|
||||
import { translateText } from "../Utils";
|
||||
|
||||
export const BUTTON_WIDTH = 150;
|
||||
@@ -26,16 +29,61 @@ export class PatternButton extends LitElement {
|
||||
@property({ type: Boolean })
|
||||
requiresPurchase: boolean = false;
|
||||
|
||||
@property({ type: Number })
|
||||
trialTimeRemaining: number = 0;
|
||||
|
||||
@property({ type: Boolean })
|
||||
allowTrial: boolean = true;
|
||||
|
||||
@property({ type: Boolean })
|
||||
trialCooldown: boolean = false;
|
||||
|
||||
@property({ type: Function })
|
||||
onSelect?: (pattern: PlayerPattern | null) => void;
|
||||
|
||||
@property({ type: Function })
|
||||
onPurchase?: (pattern: Pattern, colorPalette: ColorPalette | null) => void;
|
||||
|
||||
private _countdownInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
@state()
|
||||
private _adLoading: boolean = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string, unknown>) {
|
||||
if (changedProperties.has("trialTimeRemaining")) {
|
||||
this.setupCountdown();
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.clearCountdown();
|
||||
}
|
||||
|
||||
private setupCountdown() {
|
||||
this.clearCountdown();
|
||||
if (this.trialTimeRemaining > 0) {
|
||||
this._countdownInterval = setInterval(() => {
|
||||
this.trialTimeRemaining--;
|
||||
if (this.trialTimeRemaining <= 0) {
|
||||
this.trialTimeRemaining = 0;
|
||||
this.clearCountdown();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
private clearCountdown() {
|
||||
if (this._countdownInterval !== null) {
|
||||
clearInterval(this._countdownInterval);
|
||||
this._countdownInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
private translateCosmetic(prefix: string, patternName: string): string {
|
||||
const translation = translateText(`${prefix}.${patternName}`);
|
||||
if (translation.startsWith(prefix)) {
|
||||
@@ -60,6 +108,99 @@ export class PatternButton extends LitElement {
|
||||
} satisfies PlayerPattern);
|
||||
}
|
||||
|
||||
private async grantTrial() {
|
||||
const flare =
|
||||
this.colorPalette?.name === undefined
|
||||
? `pattern:${this.pattern!.name}`
|
||||
: `pattern:${this.pattern!.name}:${this.colorPalette.name}`;
|
||||
await grantTemporaryFlare(flare);
|
||||
new UserSettings().setSelectedPatternName(flare);
|
||||
alert(translateText("territory_patterns.trial_granted"));
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
private showSteamModal(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className =
|
||||
"fixed inset-0 bg-black/80 flex items-center justify-center z-[9999]";
|
||||
|
||||
let secondsLeft = 10;
|
||||
const updateContent = () => {
|
||||
overlay.innerHTML = `
|
||||
<div class="bg-slate-900 border border-white/20 rounded-xl p-8 max-w-md text-center">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">Wishlist on Steam!</h2>
|
||||
<p class="text-white/70 mb-6">${translateText("territory_patterns.steam_wishlist_prompt")}</p>
|
||||
<a
|
||||
href="https://store.steampowered.com/app/3560670"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-block px-6 py-3 bg-[#1b2838] hover:bg-[#2a475e] text-white font-bold rounded-lg mb-6 transition-colors"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.979 0C5.678 0 .511 4.86.022 11.037l6.432 2.658a3.387 3.387 0 0 1 1.912-.59c.064 0 .128.003.191.006l2.866-4.158v-.058c0-2.495 2.03-4.524 4.524-4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525-4.524 4.525h-.105l-4.091 2.921c0 .054.003.108.003.163 0 1.871-1.523 3.393-3.394 3.393-1.646 0-3.02-1.179-3.33-2.74L.453 15.406C1.727 20.279 6.228 24 11.979 24 18.627 24 24 18.627 24 12S18.627 0 11.979 0zM7.54 18.21l-1.473-.61c.262.543.714.999 1.314 1.25 1.297.539 2.793-.076 3.332-1.375.263-.63.264-1.319.005-1.949s-.75-1.121-1.377-1.383c-.624-.26-1.29-.249-1.878-.03l1.523.63c.956.4 1.409 1.5 1.009 2.455-.397.957-1.497 1.41-2.454 1.012zm11.415-9.303a3.015 3.015 0 0 0-3.015-3.015 3.015 3.015 0 1 0 3.015 3.015zm-5.273-.005c0-1.248 1.013-2.26 2.262-2.26a2.26 2.26 0 1 1 0 4.52 2.261 2.261 0 0 1-2.262-2.26z"/>
|
||||
</svg>
|
||||
Wishlist on Steam
|
||||
</span>
|
||||
</a>
|
||||
<div class="text-white/50 text-sm">
|
||||
${translateText("territory_patterns.reward_countdown", { seconds: secondsLeft.toString() })}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
updateContent();
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
secondsLeft--;
|
||||
if (secondsLeft <= 0) {
|
||||
clearInterval(interval);
|
||||
overlay.remove();
|
||||
resolve();
|
||||
} else {
|
||||
updateContent();
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleTryMe(e: Event) {
|
||||
e.stopPropagation();
|
||||
if (this.pattern === null || this._adLoading) return;
|
||||
|
||||
if (this.trialCooldown) {
|
||||
alert(translateText("territory_patterns.trial_cooldown"));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[PatternButton] handleTryMe called");
|
||||
this._adLoading = true;
|
||||
|
||||
try {
|
||||
console.log("[PatternButton] Calling showRewardedAd...");
|
||||
await showRewardedAd();
|
||||
console.log("[PatternButton] showRewardedAd resolved");
|
||||
await this.grantTrial();
|
||||
} catch (error) {
|
||||
console.error("[PatternButton] Rewarded ad failed:", error);
|
||||
// Show Steam wishlist modal with countdown
|
||||
await this.showSteamModal();
|
||||
await this.grantTrial();
|
||||
} finally {
|
||||
this._adLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private formatTimeRemaining(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
if (m > 0) return `${m}m ${s}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
private handlePurchase(e: Event) {
|
||||
e.stopPropagation();
|
||||
if (this.pattern?.product) {
|
||||
@@ -137,9 +278,52 @@ export class PatternButton extends LitElement {
|
||||
</div>
|
||||
</button>
|
||||
|
||||
${this.requiresPurchase && this.pattern?.product
|
||||
${(this.requiresPurchase || this.trialTimeRemaining > 0) &&
|
||||
this.pattern?.product
|
||||
? html`
|
||||
<div class="w-full mt-2">
|
||||
<div class="w-full mt-2 flex flex-col gap-2">
|
||||
${this.trialTimeRemaining > 0
|
||||
? html`
|
||||
<div
|
||||
class="w-full px-4 py-2 bg-yellow-500/20 text-yellow-400 border border-yellow-500/30 rounded-lg text-xs font-bold uppercase tracking-wider text-center"
|
||||
>
|
||||
${this.formatTimeRemaining(this.trialTimeRemaining)}
|
||||
${translateText("territory_patterns.trial_remaining")}
|
||||
</div>
|
||||
`
|
||||
: this.allowTrial
|
||||
? html`
|
||||
<button
|
||||
class="w-full px-4 py-2 bg-blue-500/20 text-blue-400 border border-blue-500/30 rounded-lg text-xs font-bold uppercase tracking-wider cursor-pointer transition-all duration-200
|
||||
hover:bg-blue-500/30 hover:shadow-[0_0_15px_rgba(59,130,246,0.2)] flex items-center justify-center gap-2"
|
||||
@click=${this.handleTryMe}
|
||||
?disabled=${this._adLoading}
|
||||
>
|
||||
${this._adLoading
|
||||
? html`<svg
|
||||
class="animate-spin h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>`
|
||||
: translateText("territory_patterns.try_me")}
|
||||
</button>
|
||||
`
|
||||
: null}
|
||||
<button
|
||||
class="w-full px-4 py-2 bg-green-500/20 text-green-400 border border-green-500/30 rounded-lg text-xs font-bold uppercase tracking-wider cursor-pointer transition-all duration-200
|
||||
hover:bg-green-500/30 hover:shadow-[0_0_15px_rgba(74,222,128,0.2)]"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { crazyGamesSDK } from "src/client/CrazyGamesSDK";
|
||||
import { getGamesPlayed } from "src/client/Utils";
|
||||
import { GameType } from "src/core/game/Game";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import "../../components/VideoReward";
|
||||
import "../../components/VideoPromo";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
@customElement("spawn-video-ad")
|
||||
|
||||
@@ -204,6 +204,7 @@ export class WinModal extends LitElement implements Layer {
|
||||
.pattern=${pattern}
|
||||
.colorPalette=${colorPalette}
|
||||
.requiresPurchase=${true}
|
||||
.allowTrial=${false}
|
||||
.onSelect=${(p: Pattern | null) => {}}
|
||||
.onPurchase=${(p: Pattern, colorPalette: ColorPalette | null) =>
|
||||
handlePurchase(p, colorPalette)}
|
||||
|
||||
@@ -56,6 +56,8 @@ export const UserMeResponseSchema = z.object({
|
||||
publicId: z.string(),
|
||||
roles: z.string().array().optional(),
|
||||
flares: z.string().array().optional(),
|
||||
flareExpiration: z.record(z.string(), z.number()).optional(),
|
||||
tempFlaresCooldown: z.boolean(),
|
||||
achievements: z
|
||||
.array(
|
||||
z.object({
|
||||
|
||||
@@ -192,6 +192,12 @@ export class UserSettings {
|
||||
}
|
||||
}
|
||||
|
||||
getFlag(): string | undefined {
|
||||
const flag = localStorage.getItem("flag");
|
||||
if (!flag || flag === "xx") return undefined;
|
||||
return flag;
|
||||
}
|
||||
|
||||
backgroundMusicVolume(): number {
|
||||
return this.getFloat("settings.backgroundMusicVolume", 0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user