mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-17 16:11:32 +00:00
Merge branch 'main' into embeddedurlfix
This commit is contained in:
+34
-26
@@ -47,34 +47,42 @@ export async function fetchPlayerById(
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export async function getUserMe(): Promise<UserMeResponse | false> {
|
||||
try {
|
||||
const userAuthResult = await userAuth();
|
||||
if (!userAuthResult) return false;
|
||||
const { jwt } = userAuthResult;
|
||||
|
||||
// Get the user object
|
||||
const response = await fetch(getApiBase() + "/users/@me", {
|
||||
headers: {
|
||||
authorization: `Bearer ${jwt}`,
|
||||
},
|
||||
});
|
||||
if (response.status === 401) {
|
||||
await logOut();
|
||||
return false;
|
||||
}
|
||||
if (response.status !== 200) return false;
|
||||
const body = await response.json();
|
||||
const result = UserMeResponseSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
const error = z.prettifyError(result.error);
|
||||
console.error("Invalid response", error);
|
||||
return false;
|
||||
}
|
||||
return result.data;
|
||||
} catch (e) {
|
||||
return false;
|
||||
let __userMe: Promise<UserMeResponse | false> | null = null;
|
||||
export async function getUserMe(): Promise<UserMeResponse | false> {
|
||||
if (__userMe !== null) {
|
||||
return __userMe;
|
||||
}
|
||||
__userMe = (async () => {
|
||||
try {
|
||||
const userAuthResult = await userAuth();
|
||||
if (!userAuthResult) return false;
|
||||
const { jwt } = userAuthResult;
|
||||
|
||||
// Get the user object
|
||||
const response = await fetch(getApiBase() + "/users/@me", {
|
||||
headers: {
|
||||
authorization: `Bearer ${jwt}`,
|
||||
},
|
||||
});
|
||||
if (response.status === 401) {
|
||||
await logOut();
|
||||
return false;
|
||||
}
|
||||
if (response.status !== 200) return false;
|
||||
const body = await response.json();
|
||||
const result = UserMeResponseSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
const error = z.prettifyError(result.error);
|
||||
console.error("Invalid response", error);
|
||||
return false;
|
||||
}
|
||||
return result.data;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
return __userMe;
|
||||
}
|
||||
|
||||
export async function createCheckoutSession(
|
||||
|
||||
+45
-16
@@ -29,23 +29,52 @@ export async function handlePurchase(
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
export async function fetchCosmetics(): Promise<Cosmetics | null> {
|
||||
try {
|
||||
const response = await fetch(`${getApiBase()}/cosmetics.json`);
|
||||
if (!response.ok) {
|
||||
console.error(`HTTP error! status: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
const result = CosmeticsSchema.safeParse(await response.json());
|
||||
if (!result.success) {
|
||||
console.error(`Invalid cosmetics: ${result.error.message}`);
|
||||
return null;
|
||||
}
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
console.error("Error getting cosmetics:", error);
|
||||
return null;
|
||||
let __cosmetics: Promise<Cosmetics | null> | null = null;
|
||||
let __cosmeticsHash: string | null = null;
|
||||
|
||||
function simpleHash(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
return hash.toString(36);
|
||||
}
|
||||
|
||||
export async function fetchCosmetics(): Promise<Cosmetics | null> {
|
||||
if (__cosmetics !== null) {
|
||||
return __cosmetics;
|
||||
}
|
||||
__cosmetics = (async () => {
|
||||
try {
|
||||
const response = await fetch(`${getApiBase()}/cosmetics.json`);
|
||||
if (!response.ok) {
|
||||
console.error(`HTTP error! status: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
const result = CosmeticsSchema.safeParse(await response.json());
|
||||
if (!result.success) {
|
||||
console.error(`Invalid cosmetics: ${result.error.message}`);
|
||||
return null;
|
||||
}
|
||||
const patternKeys = Object.keys(result.data.patterns).sort();
|
||||
const hashInput = patternKeys
|
||||
.map((k) => k + (result.data.patterns[k].product ? "sale" : ""))
|
||||
.join(",");
|
||||
__cosmeticsHash = simpleHash(hashInput);
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
console.error("Error getting cosmetics:", error);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
return __cosmetics;
|
||||
}
|
||||
|
||||
export async function getCosmeticsHash(): Promise<string | null> {
|
||||
await fetchCosmetics();
|
||||
return __cosmeticsHash;
|
||||
}
|
||||
|
||||
export function patternRelationship(
|
||||
|
||||
+155
-6
@@ -3,6 +3,28 @@ declare global {
|
||||
CrazyGames?: {
|
||||
SDK: {
|
||||
init: () => Promise<void>;
|
||||
user: {
|
||||
getUser(): Promise<{
|
||||
username: string;
|
||||
} | null>;
|
||||
addAuthListener: (
|
||||
listener: (
|
||||
user: {
|
||||
username: string;
|
||||
} | null,
|
||||
) => void,
|
||||
) => void;
|
||||
};
|
||||
ad: {
|
||||
requestAd: (
|
||||
adType: string,
|
||||
callbacks: {
|
||||
adStarted: () => void;
|
||||
adFinished: () => void;
|
||||
adError: (error: any) => void;
|
||||
},
|
||||
) => void;
|
||||
};
|
||||
game: {
|
||||
gameplayStart: () => Promise<void>;
|
||||
gameplayStop: () => Promise<void>;
|
||||
@@ -14,7 +36,9 @@ declare global {
|
||||
[key: string]: string | number;
|
||||
}) => string;
|
||||
hideInviteButton: () => void;
|
||||
inviteLink: (params: { [key: string]: string | number }) => string;
|
||||
getInviteParam: (paramName: string) => string | null;
|
||||
isInstantMultiplayer?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -24,6 +48,24 @@ declare global {
|
||||
export class CrazyGamesSDK {
|
||||
private initialized = false;
|
||||
private isGameplayActive = false;
|
||||
private readyPromise: Promise<void>;
|
||||
private resolveReady!: () => void;
|
||||
|
||||
constructor() {
|
||||
this.readyPromise = new Promise((resolve) => {
|
||||
this.resolveReady = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
async ready(): Promise<boolean> {
|
||||
const timeout = new Promise<boolean>((resolve) => {
|
||||
setTimeout(() => resolve(false), 3000);
|
||||
});
|
||||
|
||||
const ready = this.readyPromise.then(() => true);
|
||||
|
||||
return Promise.race([ready, timeout]);
|
||||
}
|
||||
|
||||
isOnCrazyGames(): boolean {
|
||||
try {
|
||||
@@ -34,9 +76,17 @@ export class CrazyGamesSDK {
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.log("[CrazyGames]: ", e);
|
||||
// If we get a cross-origin error, we're definitely iframed
|
||||
// Check our own referrer as fallback
|
||||
return document.referrer.includes("crazygames");
|
||||
const isCrazyGames = document.referrer.includes("crazygames");
|
||||
console.log("[CrazyGames], contains referrer: ", isCrazyGames);
|
||||
if (isCrazyGames) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback: on safari private we can't get referrer, so just assume we are in crazygames if in iframe
|
||||
return window.self !== window.top;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,12 +120,63 @@ export class CrazyGamesSDK {
|
||||
try {
|
||||
await window.CrazyGames.SDK.init();
|
||||
this.initialized = true;
|
||||
this.resolveReady();
|
||||
console.log("CrazyGames SDK initialized");
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize CrazyGames SDK:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async getUsername(): Promise<string | null> {
|
||||
const isReady = await this.ready();
|
||||
if (!isReady) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return (await window.CrazyGames!.SDK.user.getUser())?.username ?? null;
|
||||
} catch (e) {
|
||||
console.log("error getting CrazyGames username: ", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async addAuthListener(
|
||||
listener: (
|
||||
user: {
|
||||
username: string;
|
||||
} | null,
|
||||
) => void,
|
||||
): Promise<void> {
|
||||
if (!(await this.ready())) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("registering CrazyGames auth listener");
|
||||
window.CrazyGames!.SDK.user.addAuthListener(listener);
|
||||
} catch (error) {
|
||||
console.error("Failed to add auth listener:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async isInstantMultiplayer(): Promise<boolean> {
|
||||
const isReady = await this.ready();
|
||||
if (!isReady) {
|
||||
return false;
|
||||
}
|
||||
const gameId = await this.getInviteGameId();
|
||||
if (gameId !== null) {
|
||||
// Game id exists, meaning we are joining the game, not hosting it.
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return window.CrazyGames!.SDK.game.isInstantMultiplayer ?? false;
|
||||
} catch (e) {
|
||||
console.log("Error getting instant multiplayer: ", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async gameplayStart(): Promise<void> {
|
||||
if (!this.isReady()) {
|
||||
return;
|
||||
@@ -156,7 +257,6 @@ export class CrazyGamesSDK {
|
||||
if (!this.isReady()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const options: {
|
||||
gameId: string | number;
|
||||
@@ -165,6 +265,9 @@ export class CrazyGamesSDK {
|
||||
gameId,
|
||||
};
|
||||
const link = window.CrazyGames!.SDK.game.showInviteButton(options);
|
||||
// Store the game so we know that we are host. This way when player refreshes page,
|
||||
// It won't show up as "joining" a game we created.
|
||||
localStorage.setItem(gameId, "true");
|
||||
console.log("CrazyGames: invite button shown, link:", link);
|
||||
return link;
|
||||
} catch (error) {
|
||||
@@ -186,20 +289,66 @@ export class CrazyGamesSDK {
|
||||
}
|
||||
}
|
||||
|
||||
getInviteGameId(): string | null {
|
||||
createInviteLink(gameId: string): string | null {
|
||||
if (!this.isReady()) {
|
||||
console.warn("CrazyGames SDK not ready, cannot create invite link");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const value = window.CrazyGames!.SDK.game.getInviteParam("gameId");
|
||||
console.log(`CrazyGames: got invite gameId:`, value);
|
||||
return value;
|
||||
const link = window.CrazyGames!.SDK.game.inviteLink({ gameId });
|
||||
console.log("CrazyGames: created invite link:", link);
|
||||
return link;
|
||||
} catch (error) {
|
||||
console.error("Failed to create invite link:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getInviteGameId(): Promise<string | null> {
|
||||
if (!(await this.ready())) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const gameId = window.CrazyGames!.SDK.game.getInviteParam("gameId");
|
||||
if (gameId) {
|
||||
console.log("[CrazyGames] found invite link", gameId);
|
||||
// We already created this game, can't join a game we created.
|
||||
return localStorage.getItem(gameId) === "true" ? null : gameId;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`Failed to get invite gameId:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
requestMidgameAd(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (!this.isReady()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const callbacks = {
|
||||
adFinished: () => {
|
||||
console.log("End midgame ad");
|
||||
resolve();
|
||||
},
|
||||
adError: (error: any) => {
|
||||
console.log("Error midgame ad", error);
|
||||
resolve();
|
||||
},
|
||||
adStarted: () => console.log("Start midgame ad"),
|
||||
};
|
||||
window.CrazyGames!.SDK.ad.requestAd("midgame", callbacks);
|
||||
} catch (error) {
|
||||
console.error("Failed to request midgame ad:", error);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const crazyGamesSDK = new CrazyGamesSDK();
|
||||
|
||||
+71
-81
@@ -1,70 +1,46 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { isInIframe } from "./Utils";
|
||||
|
||||
const LEFT_FUSE = "gutter-ad-container-left";
|
||||
const RIGHT_FUSE = "gutter-ad-container-right";
|
||||
// Minimum screen width to show ads (larger than typical Chromebook)
|
||||
const MIN_SCREEN_WIDTH = 1400;
|
||||
|
||||
@customElement("gutter-ads")
|
||||
export class GutterAds extends LitElement {
|
||||
@state()
|
||||
private isVisible: boolean = false;
|
||||
|
||||
@state()
|
||||
private adLoaded: boolean = false;
|
||||
|
||||
private leftAdType: string = "standard_iab_left2";
|
||||
private rightAdType: string = "standard_iab_rght1";
|
||||
private leftContainerId: string = "gutter-ad-container-left";
|
||||
private rightContainerId: string = "gutter-ad-container-right";
|
||||
private margin: string = "10px";
|
||||
|
||||
// Override createRenderRoot to disable shadow DOM
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private readonly boundUserMeHandler = (event: Event) =>
|
||||
this.onUserMe((event as CustomEvent<UserMeResponse | false>).detail);
|
||||
static styles = css``;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
document.addEventListener(
|
||||
"userMeResponse",
|
||||
this.boundUserMeHandler as EventListener,
|
||||
);
|
||||
}
|
||||
|
||||
private onUserMe(userMeResponse: UserMeResponse | false): void {
|
||||
const flares =
|
||||
userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
|
||||
const hasFlare = flares.some((flare) => flare.startsWith("pattern:"));
|
||||
if (hasFlare) {
|
||||
console.log("No ads because you have patterns");
|
||||
window.enableAds = false;
|
||||
} else {
|
||||
console.log("No flares, showing ads");
|
||||
this.show();
|
||||
window.enableAds = true;
|
||||
}
|
||||
}
|
||||
|
||||
private isScreenLargeEnough(): boolean {
|
||||
return window.innerWidth >= MIN_SCREEN_WIDTH;
|
||||
document.addEventListener("userMeResponse", () => {
|
||||
if (window.adsEnabled) {
|
||||
console.log("showing gutter ads");
|
||||
this.show();
|
||||
} else {
|
||||
console.log("not showing gutter ads");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Called after the component's DOM is first rendered
|
||||
firstUpdated() {
|
||||
// DOM is guaranteed to be available here
|
||||
console.log("GutterAd DOM is ready");
|
||||
console.log("GutterAdModal DOM is ready");
|
||||
}
|
||||
|
||||
public show(): void {
|
||||
if (!this.isScreenLargeEnough()) {
|
||||
console.log("Screen too small for gutter ads, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInIframe()) {
|
||||
console.log("In iframe, showing gutter ads");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("showing GutterAds");
|
||||
this.isVisible = true;
|
||||
this.requestUpdate();
|
||||
|
||||
@@ -74,58 +50,57 @@ export class GutterAds extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
public hide(): void {
|
||||
this.isVisible = false;
|
||||
console.log("hiding GutterAds");
|
||||
this.destroyAds();
|
||||
document.removeEventListener(
|
||||
"userMeResponse",
|
||||
this.boundUserMeHandler as EventListener,
|
||||
);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private loadAds(): void {
|
||||
console.log("loading ramp ads");
|
||||
// Ensure the container elements exist before loading ads
|
||||
const leftContainer = this.querySelector(`#${LEFT_FUSE}`);
|
||||
const rightContainer = this.querySelector(`#${RIGHT_FUSE}`);
|
||||
const leftContainer = this.querySelector(`#${this.leftContainerId}`);
|
||||
const rightContainer = this.querySelector(`#${this.rightContainerId}`);
|
||||
|
||||
if (!leftContainer || !rightContainer) {
|
||||
console.warn("Ad containers not found in DOM");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.fusetag) {
|
||||
console.warn("Fuse tag not available");
|
||||
if (!window.ramp) {
|
||||
console.warn("Playwire RAMP not available");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.adLoaded) {
|
||||
console.log("Ads already loaded, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("registering zones");
|
||||
window.fusetag.que.push(() => {
|
||||
window.fusetag.registerZone(LEFT_FUSE);
|
||||
window.fusetag.registerZone(RIGHT_FUSE);
|
||||
window.ramp.que.push(() => {
|
||||
try {
|
||||
window.ramp.spaAddAds([
|
||||
{
|
||||
type: this.leftAdType,
|
||||
selectorId: this.leftContainerId,
|
||||
},
|
||||
{
|
||||
type: this.rightAdType,
|
||||
selectorId: this.rightContainerId,
|
||||
},
|
||||
]);
|
||||
this.adLoaded = true;
|
||||
console.log(
|
||||
"Playwire ads loaded:",
|
||||
this.leftAdType,
|
||||
this.rightAdType,
|
||||
);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to load fuse ads:", error);
|
||||
this.hide();
|
||||
console.error("Failed to load Playwire ads:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private destroyAds(): void {
|
||||
if (!window.fusetag) {
|
||||
return;
|
||||
}
|
||||
window.fusetag.que.push(() => {
|
||||
window.fusetag.destroyZone(LEFT_FUSE);
|
||||
window.fusetag.destroyZone(RIGHT_FUSE);
|
||||
});
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.hide();
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -134,11 +109,26 @@ export class GutterAds extends LitElement {
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="fixed left-0 top-1/2 -translate-y-1/2 z-10">
|
||||
<div id="${LEFT_FUSE}" data-fuse="lhs_sticky_vrec"></div>
|
||||
<!-- Left Gutter Ad -->
|
||||
<div
|
||||
class="hidden xl:flex fixed left-0 top-1/2 transform -translate-y-1/2 w-[160px] min-h-[600px] z-[100] pointer-events-auto items-center justify-center"
|
||||
style="margin-left: ${this.margin};"
|
||||
>
|
||||
<div
|
||||
id="${this.leftContainerId}"
|
||||
class="w-full h-full flex items-center justify-center p-2"
|
||||
></div>
|
||||
</div>
|
||||
<div class="fixed right-0 top-1/2 -translate-y-1/2 z-10">
|
||||
<div id="${RIGHT_FUSE}" data-fuse="rhs_sticky_vrec"></div>
|
||||
|
||||
<!-- Right Gutter Ad -->
|
||||
<div
|
||||
class="hidden xl:flex fixed right-0 top-1/2 transform -translate-y-1/2 w-[160px] min-h-[600px] z-[100] pointer-events-auto items-center justify-center"
|
||||
style="margin-right: ${this.margin};"
|
||||
>
|
||||
<div
|
||||
id="${this.rightContainerId}"
|
||||
class="w-full h-full flex items-center justify-center p-2"
|
||||
></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
+55
-2
@@ -1,6 +1,6 @@
|
||||
import { html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { translateText } from "../client/Utils";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { translateText, TUTORIAL_VIDEO_URL } from "../client/Utils";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/Difficulties";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
@@ -9,6 +9,7 @@ import { TroubleshootingModal } from "./TroubleshootingModal";
|
||||
@customElement("help-modal")
|
||||
export class HelpModal extends BaseModal {
|
||||
@state() private keybinds: Record<string, string> = this.getKeybinds();
|
||||
@query("#tutorial-video-iframe") private videoIframe?: HTMLIFrameElement;
|
||||
|
||||
private isKeybindObject(v: unknown): v is { value: string } {
|
||||
return (
|
||||
@@ -120,6 +121,47 @@ export class HelpModal extends BaseModal {
|
||||
[&_p]:text-gray-300 [&_p]:mb-3 [&_strong]:text-white [&_strong]:font-bold
|
||||
scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent"
|
||||
>
|
||||
<!-- Video Tutorial Section -->
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="text-blue-400">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polygon points="5 3 19 12 5 21 5 3"></polygon>
|
||||
</svg>
|
||||
</div>
|
||||
<h3
|
||||
class="text-xl font-bold uppercase tracking-widest text-white/90"
|
||||
>
|
||||
${translateText("help_modal.video_tutorial")}
|
||||
</h3>
|
||||
<div
|
||||
class="flex-1 h-px bg-gradient-to-r from-blue-500/50 to-transparent"
|
||||
></div>
|
||||
</div>
|
||||
<section
|
||||
class="bg-white/5 rounded-xl border border-white/10 overflow-hidden mb-8"
|
||||
>
|
||||
<div class="relative w-full h-0 pb-[56.25%]">
|
||||
<iframe
|
||||
id="tutorial-video-iframe"
|
||||
class="absolute top-0 left-0 w-full h-full"
|
||||
src="${TUTORIAL_VIDEO_URL}"
|
||||
title="${translateText("help_modal.video_tutorial_title")}"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Troubleshooting Section -->
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="text-blue-400">
|
||||
@@ -1200,5 +1242,16 @@ export class HelpModal extends BaseModal {
|
||||
|
||||
protected onOpen(): void {
|
||||
this.keybinds = this.getKeybinds();
|
||||
// Restore the video src when modal opens
|
||||
if (this.videoIframe) {
|
||||
this.videoIframe.src = TUTORIAL_VIDEO_URL;
|
||||
}
|
||||
}
|
||||
|
||||
protected onClose(): void {
|
||||
// Clear the iframe src to stop video playback
|
||||
if (this.videoIframe) {
|
||||
this.videoIframe.src = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,12 @@ export class HostLobbyModal extends BaseModal {
|
||||
}
|
||||
|
||||
private async buildLobbyUrl(): Promise<string> {
|
||||
if (crazyGamesSDK.isOnCrazyGames()) {
|
||||
const link = crazyGamesSDK.createInviteLink(this.lobbyId);
|
||||
if (link !== null) {
|
||||
return link;
|
||||
}
|
||||
}
|
||||
const config = await getServerConfigFromClient();
|
||||
return `${window.location.origin}/${config.workerPath(this.lobbyId)}/game/${this.lobbyId}?lobby&s=${encodeURIComponent(this.lobbyUrlSuffix)}`;
|
||||
}
|
||||
@@ -125,7 +131,9 @@ export class HostLobbyModal extends BaseModal {
|
||||
}
|
||||
|
||||
private updateHistory(url: string): void {
|
||||
history.replaceState(null, "", url);
|
||||
if (!crazyGamesSDK.isOnCrazyGames()) {
|
||||
history.replaceState(null, "", url);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
@@ -228,6 +228,7 @@ export class LangSelector extends LitElement {
|
||||
"stats-modal",
|
||||
"flag-input-modal",
|
||||
"flag-input",
|
||||
"matchmaking-button",
|
||||
"token-login",
|
||||
];
|
||||
|
||||
|
||||
+79
-92
@@ -43,7 +43,7 @@ import {
|
||||
} from "./Transport";
|
||||
import { UserSettingModal } from "./UserSettingModal";
|
||||
import "./UsernameInput";
|
||||
import { UsernameInput } from "./UsernameInput";
|
||||
import { genAnonUsername, UsernameInput } from "./UsernameInput";
|
||||
import {
|
||||
getDiscordAvatarUrl,
|
||||
incrementGamesPlayed,
|
||||
@@ -163,19 +163,12 @@ declare global {
|
||||
GIT_COMMIT: string;
|
||||
INSTANCE_ID: string;
|
||||
turnstile: any;
|
||||
enableAds: boolean;
|
||||
adsEnabled: boolean;
|
||||
PageOS: {
|
||||
session: {
|
||||
newPageView: () => void;
|
||||
};
|
||||
};
|
||||
fusetag: {
|
||||
registerZone: (id: string) => void;
|
||||
destroyZone: (id: string) => void;
|
||||
pageInit: (options?: any) => void;
|
||||
que: Array<() => void>;
|
||||
destroySticky: () => void;
|
||||
};
|
||||
ramp: {
|
||||
que: Array<() => void>;
|
||||
passiveMode: boolean;
|
||||
@@ -184,7 +177,25 @@ declare global {
|
||||
settings?: {
|
||||
slots?: any;
|
||||
};
|
||||
spaNewPage: (url: string) => void;
|
||||
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;
|
||||
}
|
||||
@@ -216,6 +227,7 @@ class Client {
|
||||
private usernameInput: UsernameInput | null = null;
|
||||
private flagInput: FlagInput | null = null;
|
||||
|
||||
private hostModal: HostPrivateLobbyModal;
|
||||
private joinModal: JoinPrivateLobbyModal;
|
||||
private publicLobby: PublicLobby;
|
||||
private userSettings: UserSettings = new UserSettings();
|
||||
@@ -433,57 +445,14 @@ class Client {
|
||||
) {
|
||||
console.warn("Matchmaking modal element not found");
|
||||
}
|
||||
const matchmakingButton = document.getElementById("matchmaking-button");
|
||||
const matchmakingButtonLoggedOut = document.getElementById(
|
||||
"matchmaking-button-logged-out",
|
||||
);
|
||||
|
||||
const updateMatchmakingButton = (loggedIn: boolean) => {
|
||||
if (!loggedIn) {
|
||||
matchmakingButton?.classList.add("hidden");
|
||||
matchmakingButtonLoggedOut?.classList.remove("hidden");
|
||||
} else {
|
||||
matchmakingButton?.classList.remove("hidden");
|
||||
matchmakingButtonLoggedOut?.classList.add("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
if (matchmakingButton) {
|
||||
matchmakingButton.addEventListener("click", () => {
|
||||
if (this.usernameInput?.isValid()) {
|
||||
window.showPage?.("page-matchmaking");
|
||||
this.publicLobby.leaveLobby();
|
||||
} else {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
message: this.usernameInput?.validationError,
|
||||
color: "red",
|
||||
duration: 3000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (matchmakingButtonLoggedOut) {
|
||||
matchmakingButtonLoggedOut.addEventListener("click", () => {
|
||||
window.showPage?.("page-account");
|
||||
});
|
||||
}
|
||||
|
||||
const onUserMe = async (userMeResponse: UserMeResponse | false) => {
|
||||
// Check if user has actual authentication (discord or email), not just a publicId
|
||||
const loggedIn =
|
||||
userMeResponse !== false &&
|
||||
userMeResponse !== null &&
|
||||
typeof userMeResponse === "object" &&
|
||||
userMeResponse.user &&
|
||||
(userMeResponse.user.discord !== undefined ||
|
||||
userMeResponse.user.email !== undefined);
|
||||
updateMatchmakingButton(loggedIn);
|
||||
updateAccountNavButton(userMeResponse);
|
||||
const hasLinkedAccount =
|
||||
!crazyGamesSDK.isOnCrazyGames() &&
|
||||
((userMeResponse || null)?.player?.flares?.length ?? 0) > 0;
|
||||
console.log("ads enabled: ", hasLinkedAccount);
|
||||
window.adsEnabled = !hasLinkedAccount && !crazyGamesSDK.isOnCrazyGames();
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("userMeResponse", {
|
||||
detail: userMeResponse,
|
||||
@@ -524,10 +493,10 @@ class Client {
|
||||
}
|
||||
});
|
||||
|
||||
const hostModal = document.querySelector(
|
||||
this.hostModal = document.querySelector(
|
||||
"host-lobby-modal",
|
||||
) as HostPrivateLobbyModal;
|
||||
if (!hostModal || !(hostModal instanceof HostPrivateLobbyModal)) {
|
||||
if (!this.hostModal || !(this.hostModal instanceof HostPrivateLobbyModal)) {
|
||||
console.warn("Host private lobby modal element not found");
|
||||
}
|
||||
const hostLobbyButton = document.getElementById("host-lobby-button");
|
||||
@@ -583,7 +552,11 @@ class Client {
|
||||
}
|
||||
|
||||
// Attempt to join lobby
|
||||
this.handleUrl();
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", () => this.handleUrl());
|
||||
} else {
|
||||
this.handleUrl();
|
||||
}
|
||||
|
||||
const onHashUpdate = () => {
|
||||
// Reset the UI to its initial state
|
||||
@@ -653,21 +626,38 @@ class Client {
|
||||
updateSliderProgress(slider);
|
||||
slider.addEventListener("input", () => updateSliderProgress(slider));
|
||||
});
|
||||
|
||||
this.initializeFuseTag();
|
||||
}
|
||||
|
||||
private handleUrl() {
|
||||
private async handleUrl() {
|
||||
// Wait for modal custom elements to be defined
|
||||
await Promise.all([
|
||||
customElements.whenDefined("join-private-lobby-modal"),
|
||||
customElements.whenDefined("host-lobby-modal"),
|
||||
]);
|
||||
|
||||
// Check if CrazyGames SDK is enabled first (no hash needed in CrazyGames)
|
||||
if (crazyGamesSDK.isOnCrazyGames()) {
|
||||
const lobbyId = crazyGamesSDK.getInviteGameId();
|
||||
const lobbyId = await crazyGamesSDK.getInviteGameId();
|
||||
console.log("got game id", lobbyId);
|
||||
if (lobbyId && GAME_ID_REGEX.test(lobbyId)) {
|
||||
console.log("game parsed successfully");
|
||||
// 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");
|
||||
this.joinModal?.open(lobbyId);
|
||||
console.log(`CrazyGames: joining lobby ${lobbyId} from invite param`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
crazyGamesSDK.isInstantMultiplayer().then((isInstant) => {
|
||||
if (isInstant) {
|
||||
console.log(
|
||||
`CrazyGames: joining instant multiplayer lobby from CrazyGames`,
|
||||
);
|
||||
this.hostModal.open();
|
||||
}
|
||||
});
|
||||
|
||||
const strip = () =>
|
||||
history.replaceState(
|
||||
@@ -790,7 +780,8 @@ class Client {
|
||||
: this.flagInput.getCurrentFlag(),
|
||||
},
|
||||
turnstileToken: await this.getTurnstileToken(lobby),
|
||||
playerName: this.usernameInput?.getCurrentUsername() ?? "",
|
||||
playerName:
|
||||
this.usernameInput?.getCurrentUsername() ?? genAnonUsername(),
|
||||
clientID: lobby.clientID,
|
||||
gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info,
|
||||
gameRecord: lobby.gameRecord,
|
||||
@@ -848,7 +839,6 @@ class Client {
|
||||
if (startingModal && startingModal instanceof GameStartingModal) {
|
||||
startingModal.show();
|
||||
}
|
||||
this.gutterAds.hide();
|
||||
},
|
||||
() => {
|
||||
this.joinModal.close();
|
||||
@@ -859,6 +849,9 @@ class Client {
|
||||
(ad as HTMLElement).style.display = "none";
|
||||
});
|
||||
|
||||
if (window.PageOS?.session?.newPageView) {
|
||||
window.PageOS.session.newPageView();
|
||||
}
|
||||
crazyGamesSDK.loadingStop();
|
||||
crazyGamesSDK.gameplayStart();
|
||||
document.body.classList.add("in-game");
|
||||
@@ -909,8 +902,6 @@ class Client {
|
||||
document.body.classList.remove("in-game");
|
||||
|
||||
crazyGamesSDK.gameplayStop();
|
||||
|
||||
this.gutterAds.hide();
|
||||
this.publicLobby.leaveLobby();
|
||||
}
|
||||
|
||||
@@ -932,28 +923,6 @@ class Client {
|
||||
}
|
||||
}
|
||||
|
||||
private initializeFuseTag() {
|
||||
const tryInitFuseTag = (): boolean => {
|
||||
if (window.fusetag && typeof window.fusetag.pageInit === "function") {
|
||||
console.log("initializing fuse tag");
|
||||
window.fusetag.que.push(() => {
|
||||
window.fusetag.pageInit({
|
||||
blockingFuseIds: ["lhs_sticky_vrec", "rhs_sticky_vrec"],
|
||||
});
|
||||
});
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (tryInitFuseTag()) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
private async getTurnstileToken(
|
||||
lobby: JoinLobbyEvent,
|
||||
): Promise<string | null> {
|
||||
@@ -965,7 +934,8 @@ class Client {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.turnstileTokenPromise === null) {
|
||||
// Always request a new token on crazygames.
|
||||
if (this.turnstileTokenPromise === null || crazyGamesSDK.isOnCrazyGames()) {
|
||||
console.log("No prefetched turnstile token, getting new token");
|
||||
return (await getTurnstileToken())?.token ?? null;
|
||||
}
|
||||
@@ -981,6 +951,7 @@ class Client {
|
||||
const tokenTTL = 3 * 60 * 1000;
|
||||
if (Date.now() < token.createdAt + tokenTTL) {
|
||||
console.log("Prefetched turnstile token is valid");
|
||||
|
||||
return token.token;
|
||||
} else {
|
||||
console.log("Turnstile token expired, getting new token");
|
||||
@@ -989,11 +960,27 @@ class Client {
|
||||
}
|
||||
}
|
||||
|
||||
// Hide elements with no-crazygames class if on CrazyGames
|
||||
const hideCrazyGamesElements = () => {
|
||||
if (crazyGamesSDK.isOnCrazyGames()) {
|
||||
document.querySelectorAll(".no-crazygames").forEach((el) => {
|
||||
(el as HTMLElement).style.display = "none";
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize the client when the DOM is loaded
|
||||
const bootstrap = () => {
|
||||
initLayout();
|
||||
new Client().initialize();
|
||||
initNavigation();
|
||||
|
||||
// Hide elements immediately
|
||||
hideCrazyGamesElements();
|
||||
|
||||
// Also hide elements after a short delay to catch late-rendered components
|
||||
setTimeout(hideCrazyGamesElements, 100);
|
||||
setTimeout(hideCrazyGamesElements, 500);
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
|
||||
+86
-33
@@ -3,7 +3,7 @@ import { customElement, query, state } from "lit/decorators.js";
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
||||
import { generateID } from "../core/Util";
|
||||
import { getUserMe } from "./Api";
|
||||
import { getUserMe, hasLinkedAccount } from "./Api";
|
||||
import { getPlayToken } from "./Auth";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/Difficulties";
|
||||
@@ -15,24 +15,15 @@ import { translateText } from "./Utils";
|
||||
@customElement("matchmaking-modal")
|
||||
export class MatchmakingModal extends BaseModal {
|
||||
private gameCheckInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private connectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
@state() private connected = false;
|
||||
@state() private socket: WebSocket | null = null;
|
||||
@state() private gameID: string | null = null;
|
||||
private elo = "unknown";
|
||||
private elo: number | "unknown" = "unknown";
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.id = "page-matchmaking";
|
||||
document.addEventListener("userMeResponse", (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
if (customEvent.detail) {
|
||||
const userMeResponse = customEvent.detail as UserMeResponse;
|
||||
this.elo =
|
||||
userMeResponse.player?.leaderboard?.oneVone?.elo?.toString() ??
|
||||
"unknown";
|
||||
this.requestUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
@@ -125,18 +116,24 @@ export class MatchmakingModal extends BaseModal {
|
||||
);
|
||||
this.socket.onopen = async () => {
|
||||
console.log("Connected to matchmaking server");
|
||||
setTimeout(() => {
|
||||
this.connectTimeout = setTimeout(async () => {
|
||||
if (this.socket?.readyState !== WebSocket.OPEN) {
|
||||
console.warn("[Matchmaking] socket not ready");
|
||||
return;
|
||||
}
|
||||
// Set a delay so the user can see the "connecting" message,
|
||||
// otherwise the "searching" message will be shown immediately.
|
||||
// Also wait so people who back out immediately aren't added
|
||||
// to the matchmaking queue.
|
||||
this.socket.send(
|
||||
JSON.stringify({
|
||||
type: "join",
|
||||
jwt: await getPlayToken(),
|
||||
}),
|
||||
);
|
||||
this.connected = true;
|
||||
this.requestUpdate();
|
||||
}, 1000);
|
||||
this.socket?.send(
|
||||
JSON.stringify({
|
||||
type: "join",
|
||||
jwt: await getPlayToken(),
|
||||
}),
|
||||
);
|
||||
}, 2000);
|
||||
};
|
||||
this.socket.onmessage = (event) => {
|
||||
console.log(event.data);
|
||||
@@ -145,6 +142,7 @@ export class MatchmakingModal extends BaseModal {
|
||||
this.socket?.close();
|
||||
console.log(`matchmaking: got game ID: ${data.gameId}`);
|
||||
this.gameID = data.gameId;
|
||||
this.gameCheckInterval = setInterval(() => this.checkGame(), 1000);
|
||||
}
|
||||
};
|
||||
this.socket.onerror = (event: ErrorEvent) => {
|
||||
@@ -157,7 +155,6 @@ export class MatchmakingModal extends BaseModal {
|
||||
|
||||
protected async onOpen(): Promise<void> {
|
||||
const userMe = await getUserMe();
|
||||
|
||||
// Early return if modal was closed during async operation
|
||||
if (!this.isModalOpen) {
|
||||
return;
|
||||
@@ -180,15 +177,21 @@ export class MatchmakingModal extends BaseModal {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
|
||||
this.elo = userMe.player.leaderboard?.oneVone?.elo ?? "unknown";
|
||||
|
||||
this.connected = false;
|
||||
this.gameID = null;
|
||||
this.connect();
|
||||
this.gameCheckInterval = setInterval(() => this.checkGame(), 1000);
|
||||
}
|
||||
|
||||
protected onClose(): void {
|
||||
this.connected = false;
|
||||
this.socket?.close();
|
||||
if (this.connectTimeout) {
|
||||
clearTimeout(this.connectTimeout);
|
||||
this.connectTimeout = null;
|
||||
}
|
||||
if (this.gameCheckInterval) {
|
||||
clearInterval(this.gameCheckInterval);
|
||||
this.gameCheckInterval = null;
|
||||
@@ -240,6 +243,7 @@ export class MatchmakingModal extends BaseModal {
|
||||
@customElement("matchmaking-button")
|
||||
export class MatchmakingButton extends LitElement {
|
||||
@query("matchmaking-modal") private matchmakingModal?: MatchmakingModal;
|
||||
@state() private isLoggedIn = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -247,6 +251,14 @@ export class MatchmakingButton extends LitElement {
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
// Listen for user authentication changes
|
||||
document.addEventListener("userMeResponse", (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
if (customEvent.detail) {
|
||||
const userMeResponse = customEvent.detail as UserMeResponse | false;
|
||||
this.isLoggedIn = hasLinkedAccount(userMeResponse);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
@@ -254,17 +266,58 @@ export class MatchmakingButton extends LitElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="z-9999">
|
||||
<o-button
|
||||
@click="${this.open}"
|
||||
translationKey="matchmaking_modal.title"
|
||||
block
|
||||
secondary
|
||||
></o-button>
|
||||
</div>
|
||||
<matchmaking-modal></matchmaking-modal>
|
||||
`;
|
||||
return this.isLoggedIn
|
||||
? html`
|
||||
<button
|
||||
@click="${this.handleLoggedInClick}"
|
||||
class="no-crazygames w-full h-20 bg-purple-600 hover:bg-purple-500 text-white font-black uppercase tracking-widest rounded-xl transition-all duration-200 flex flex-col items-center justify-center group overflow-hidden relative"
|
||||
title="${translateText("matchmaking_modal.title")}"
|
||||
>
|
||||
<span class="relative z-10 text-2xl">
|
||||
${translateText("matchmaking_button.play_ranked")}
|
||||
</span>
|
||||
<span
|
||||
class="relative z-10 text-xs font-medium text-purple-100 opacity-90 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
${translateText("matchmaking_button.description")}
|
||||
</span>
|
||||
</button>
|
||||
<matchmaking-modal></matchmaking-modal>
|
||||
`
|
||||
: html`
|
||||
<button
|
||||
@click="${this.handleLoggedOutClick}"
|
||||
class="no-crazygames w-full h-20 bg-purple-600 hover:bg-purple-500 text-white font-black uppercase tracking-widest rounded-xl transition-all duration-200 flex flex-col items-center justify-center overflow-hidden relative cursor-pointer"
|
||||
>
|
||||
<span class="relative z-10 text-2xl">
|
||||
${translateText("matchmaking_button.login_required")}
|
||||
</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private handleLoggedInClick() {
|
||||
const usernameInput = document.querySelector("username-input") as any;
|
||||
const publicLobby = document.querySelector("public-lobby") as any;
|
||||
|
||||
if (usernameInput?.isValid()) {
|
||||
this.open();
|
||||
publicLobby?.leaveLobby();
|
||||
} else {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
message: usernameInput?.validationError,
|
||||
color: "red",
|
||||
duration: 3000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private handleLoggedOutClick() {
|
||||
window.showPage?.("page-account");
|
||||
}
|
||||
|
||||
private open() {
|
||||
|
||||
@@ -5,22 +5,9 @@ import { UserSettings } from "../core/game/UserSettings";
|
||||
import { PlayerPattern } from "../core/Schemas";
|
||||
import { renderPatternPreview } from "./components/PatternButton";
|
||||
import { fetchCosmetics } from "./Cosmetics";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
// Module-level cosmetics cache to avoid refetching on every component mount
|
||||
let cosmeticsCache: Promise<Cosmetics | null> | null = null;
|
||||
|
||||
function getCachedCosmetics(): Promise<Cosmetics | null> {
|
||||
if (!cosmeticsCache) {
|
||||
const fetchPromise = fetchCosmetics();
|
||||
cosmeticsCache = fetchPromise.catch((err) => {
|
||||
cosmeticsCache = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return cosmeticsCache;
|
||||
}
|
||||
|
||||
@customElement("pattern-input")
|
||||
export class PatternInput extends LitElement {
|
||||
@state() public pattern: PlayerPattern | null = null;
|
||||
@@ -63,7 +50,7 @@ export class PatternInput extends LitElement {
|
||||
super.connectedCallback();
|
||||
this._abortController = new AbortController();
|
||||
this.isLoading = true;
|
||||
const cosmetics = await getCachedCosmetics();
|
||||
const cosmetics = await fetchCosmetics();
|
||||
if (!this.isConnected) return;
|
||||
this.cosmetics = cosmetics;
|
||||
this.updateFromSettings();
|
||||
@@ -87,6 +74,10 @@ export class PatternInput extends LitElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
if (crazyGamesSDK.isOnCrazyGames()) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const isDefault = this.pattern === null && this.selectedColor === null;
|
||||
const showSelect = this.showSelectLabel && isDefault;
|
||||
const buttonTitle = translateText("territory_patterns.title");
|
||||
|
||||
@@ -26,6 +26,7 @@ import "./components/FluentSlider";
|
||||
import "./components/map/MapPicker";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import { fetchCosmetics } from "./Cosmetics";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
import { FlagInput } from "./FlagInput";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
import { UsernameInput } from "./UsernameInput";
|
||||
@@ -91,6 +92,9 @@ export class SinglePlayerModal extends BaseModal {
|
||||
};
|
||||
|
||||
private renderNotLoggedInBanner(): TemplateResult {
|
||||
if (crazyGamesSDK.isOnCrazyGames()) {
|
||||
return html``;
|
||||
}
|
||||
return html`<div
|
||||
class="px-3 py-2 text-xs font-bold uppercase tracking-wider transition-colors duration-200 rounded-lg bg-yellow-500/20 text-yellow-400 border border-yellow-500/30 whitespace-nowrap shrink-0"
|
||||
>
|
||||
@@ -850,6 +854,8 @@ export class SinglePlayerModal extends BaseModal {
|
||||
|
||||
const selectedColor = this.userSettings.getSelectedColor();
|
||||
|
||||
await crazyGamesSDK.requestMidgameAd();
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
|
||||
+26
-11
@@ -8,6 +8,7 @@ import {
|
||||
MIN_USERNAME_LENGTH,
|
||||
validateUsername,
|
||||
} from "../core/validations/username";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
|
||||
const usernameKey: string = "username";
|
||||
|
||||
@@ -39,8 +40,20 @@ export class UsernameInput extends LitElement {
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
const stored = this.getStoredUsername();
|
||||
const stored = this.getUsername();
|
||||
this.parseAndSetUsername(stored);
|
||||
crazyGamesSDK.getUsername().then((username) => {
|
||||
if (username) {
|
||||
this.parseAndSetUsername(username ?? genAnonUsername());
|
||||
this.requestUpdate();
|
||||
}
|
||||
});
|
||||
crazyGamesSDK.addAuthListener((user) => {
|
||||
if (user) {
|
||||
this.parseAndSetUsername(user?.username);
|
||||
}
|
||||
this.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
private parseAndSetUsername(fullUsername: string) {
|
||||
@@ -52,6 +65,8 @@ export class UsernameInput extends LitElement {
|
||||
this.clanTag = "";
|
||||
this.baseUsername = fullUsername;
|
||||
}
|
||||
|
||||
this.validateAndStore();
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -161,7 +176,7 @@ export class UsernameInput extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private getStoredUsername(): string {
|
||||
private getUsername(): string {
|
||||
const storedUsername = localStorage.getItem(usernameKey);
|
||||
if (storedUsername) {
|
||||
return storedUsername;
|
||||
@@ -176,20 +191,20 @@ export class UsernameInput extends LitElement {
|
||||
}
|
||||
|
||||
private generateNewUsername(): string {
|
||||
const newUsername = "Anon" + this.uuidToThreeDigits();
|
||||
const newUsername = genAnonUsername();
|
||||
this.storeUsername(newUsername);
|
||||
return newUsername;
|
||||
}
|
||||
|
||||
private uuidToThreeDigits(): string {
|
||||
const uuid = uuidv4();
|
||||
const cleanUuid = uuid.replace(/-/g, "").toLowerCase();
|
||||
const decimal = BigInt(`0x${cleanUuid}`);
|
||||
const threeDigits = decimal % 1000n;
|
||||
return threeDigits.toString().padStart(3, "0");
|
||||
}
|
||||
|
||||
public isValid(): boolean {
|
||||
return this._isValid;
|
||||
}
|
||||
}
|
||||
|
||||
export function genAnonUsername(): string {
|
||||
const uuid = uuidv4();
|
||||
const cleanUuid = uuid.replace(/-/g, "").toLowerCase();
|
||||
const decimal = BigInt(`0x${cleanUuid}`);
|
||||
const threeDigits = decimal % 1000n;
|
||||
return "Anon" + threeDigits.toString().padStart(3, "0");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import IntlMessageFormat from "intl-messageformat";
|
||||
import { MessageType } from "../core/game/Game";
|
||||
import type { LangSelector } from "./LangSelector";
|
||||
|
||||
export const TUTORIAL_VIDEO_URL = "https://www.youtube.com/embed/EN2oOog3pSs";
|
||||
|
||||
export function renderDuration(totalSeconds: number): string {
|
||||
if (totalSeconds <= 0) return "0s";
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
|
||||
@@ -25,6 +25,16 @@ export abstract class BaseModal extends LitElement {
|
||||
return this;
|
||||
}
|
||||
|
||||
protected firstUpdated(): void {
|
||||
if (this.modalEl) {
|
||||
this.modalEl.onClose = () => {
|
||||
if (this.isModalOpen) {
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.unregisterEscapeHandler();
|
||||
super.disconnectedCallback();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { LitElement, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { getServerConfigFromClient } from "../../core/configuration/ConfigLoader";
|
||||
import { UserSettings } from "../../core/game/UserSettings";
|
||||
import { crazyGamesSDK } from "../CrazyGamesSDK";
|
||||
import { copyToClipboard, translateText } from "../Utils";
|
||||
|
||||
@customElement("copy-button")
|
||||
@@ -73,15 +74,21 @@ export class CopyButton extends LitElement {
|
||||
return url;
|
||||
}
|
||||
|
||||
private async resolveCopyText(): Promise<string> {
|
||||
private async resolveCopyText(): Promise<string | null> {
|
||||
if (this.copyText) return this.copyText;
|
||||
if (crazyGamesSDK.isOnCrazyGames()) {
|
||||
return crazyGamesSDK.createInviteLink(this.lobbyId);
|
||||
}
|
||||
if (!this.lobbyId) return "";
|
||||
return await this.buildCopyUrl();
|
||||
}
|
||||
|
||||
private async handleCopy() {
|
||||
const text = await this.resolveCopyText();
|
||||
if (!text) return;
|
||||
if (!text) {
|
||||
alert("Error copying game id");
|
||||
return;
|
||||
}
|
||||
await copyToClipboard(
|
||||
text,
|
||||
() => (this.copySuccess = true),
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { getCosmeticsHash } from "../Cosmetics";
|
||||
import { getGamesPlayed } from "../Utils";
|
||||
|
||||
const HELP_SEEN_KEY = "helpSeen";
|
||||
const STORE_SEEN_HASH_KEY = "storeSeenHash";
|
||||
|
||||
@customElement("desktop-nav-bar")
|
||||
export class DesktopNavBar extends LitElement {
|
||||
@state() private _helpSeen = localStorage.getItem(HELP_SEEN_KEY) === "true";
|
||||
@state() private _hasNewCosmetics = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
@@ -18,6 +26,12 @@ export class DesktopNavBar extends LitElement {
|
||||
this._updateActiveState(current);
|
||||
});
|
||||
}
|
||||
|
||||
// Check if cosmetics have changed
|
||||
getCosmeticsHash().then((hash: string | null) => {
|
||||
const seenHash = localStorage.getItem(STORE_SEEN_HASH_KEY);
|
||||
this._hasNewCosmetics = hash !== null && hash !== seenHash;
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
@@ -40,6 +54,30 @@ export class DesktopNavBar extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private showHelpDot(): boolean {
|
||||
// Only show one dot at a time to prevent
|
||||
// overwhelming users.
|
||||
return getGamesPlayed() < 10 && !this._helpSeen;
|
||||
}
|
||||
|
||||
private showStoreDot(): boolean {
|
||||
return this._hasNewCosmetics && !this.showHelpDot();
|
||||
}
|
||||
|
||||
private onHelpClick = () => {
|
||||
localStorage.setItem(HELP_SEEN_KEY, "true");
|
||||
this._helpSeen = true;
|
||||
};
|
||||
|
||||
private onStoreClick = () => {
|
||||
this._hasNewCosmetics = false;
|
||||
getCosmeticsHash().then((hash: string | null) => {
|
||||
if (hash !== null) {
|
||||
localStorage.setItem(STORE_SEEN_HASH_KEY, hash);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<nav
|
||||
@@ -104,11 +142,24 @@ export class DesktopNavBar extends LitElement {
|
||||
data-page="page-news"
|
||||
data-i18n="main.news"
|
||||
></button>
|
||||
<button
|
||||
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500 relative"
|
||||
data-page="page-item-store"
|
||||
data-i18n="main.store"
|
||||
></button>
|
||||
<div class="relative no-crazygames">
|
||||
<button
|
||||
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
data-page="page-item-store"
|
||||
data-i18n="main.store"
|
||||
@click=${this.onStoreClick}
|
||||
></button>
|
||||
${this.showStoreDot()
|
||||
? html`
|
||||
<span
|
||||
class="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full animate-ping"
|
||||
></span>
|
||||
<span
|
||||
class="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full"
|
||||
></span>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
<button
|
||||
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
data-page="page-settings"
|
||||
@@ -119,22 +170,35 @@ export class DesktopNavBar extends LitElement {
|
||||
data-page="page-stats"
|
||||
data-i18n="main.stats"
|
||||
></button>
|
||||
<button
|
||||
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
data-page="page-help"
|
||||
data-i18n="main.help"
|
||||
></button>
|
||||
<div class="relative">
|
||||
<button
|
||||
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
data-page="page-help"
|
||||
data-i18n="main.help"
|
||||
@click=${this.onHelpClick}
|
||||
></button>
|
||||
${this.showHelpDot()
|
||||
? html`
|
||||
<span
|
||||
class="absolute -top-1 -right-1 w-2 h-2 bg-yellow-400 rounded-full animate-ping"
|
||||
></span>
|
||||
<span
|
||||
class="absolute -top-1 -right-1 w-2 h-2 bg-yellow-400 rounded-full"
|
||||
></span>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
<lang-selector></lang-selector>
|
||||
<button
|
||||
id="nav-account-button"
|
||||
class="nav-menu-item relative h-10 rounded-full overflow-hidden flex items-center justify-center gap-2 px-3 bg-transparent border border-white/20 text-white/80 hover:text-white cursor-pointer transition-colors [&.active]:text-white"
|
||||
class="no-crazygames nav-menu-item relative h-10 rounded-full overflow-hidden flex items-center justify-center gap-2 px-3 bg-transparent border border-white/20 text-white/80 hover:text-white cursor-pointer transition-colors [&.active]:text-white"
|
||||
data-page="page-account"
|
||||
data-i18n-aria-label="main.account"
|
||||
data-i18n-title="main.account"
|
||||
>
|
||||
<img
|
||||
id="nav-account-avatar"
|
||||
class="hidden w-8 h-8 rounded-full object-cover"
|
||||
class="no-crazygames hidden w-8 h-8 rounded-full object-cover"
|
||||
alt=""
|
||||
data-i18n-alt="main.discord_avatar_alt"
|
||||
referrerpolicy="no-referrer"
|
||||
|
||||
@@ -124,18 +124,24 @@ export class MobileNavBar extends LitElement {
|
||||
data-page="page-stats"
|
||||
data-i18n="main.stats"
|
||||
></button>
|
||||
<button
|
||||
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
|
||||
data-page="page-item-store"
|
||||
data-i18n="main.store"
|
||||
></button>
|
||||
<div class="relative no-crazygames">
|
||||
<button
|
||||
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
|
||||
data-page="page-item-store"
|
||||
data-i18n="main.store"
|
||||
></button>
|
||||
<span
|
||||
class="absolute top-[45%] -translate-y-1/2 right-4 bg-gradient-to-br from-red-600 to-red-700 text-white text-[10px] font-black tracking-wider px-2.5 py-1 rounded rotate-12 shadow-lg shadow-red-600/50 animate-pulse pointer-events-none"
|
||||
data-i18n="main.store_new_badge"
|
||||
></span>
|
||||
</div>
|
||||
<button
|
||||
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
|
||||
data-page="page-settings"
|
||||
data-i18n="main.settings"
|
||||
></button>
|
||||
<button
|
||||
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
|
||||
class="no-crazygames nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
|
||||
data-page="page-account"
|
||||
data-i18n="main.account"
|
||||
></button>
|
||||
|
||||
@@ -72,7 +72,7 @@ export class PatternButton extends LitElement {
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="flex flex-col items-center justify-between gap-2 p-3 bg-white/5 backdrop-blur-sm border rounded-xl w-48 h-full transition-all duration-200 ${this
|
||||
class="no-crazygames flex flex-col items-center justify-between gap-2 p-3 bg-white/5 backdrop-blur-sm border rounded-xl w-48 h-full transition-all duration-200 ${this
|
||||
.selected
|
||||
? "border-green-500 shadow-[0_0_15px_rgba(34,197,94,0.5)]"
|
||||
: "hover:bg-white/10 hover:border-white/20 hover:shadow-xl border-white/10"}"
|
||||
|
||||
@@ -138,32 +138,7 @@ export class PlayPage extends LitElement {
|
||||
|
||||
<!-- Matchmaking Buttons (Full Width across entire grid) -->
|
||||
<div class="lg:col-span-12 flex flex-col gap-6">
|
||||
<!-- Not Logged In Button -->
|
||||
<button
|
||||
id="matchmaking-button-logged-out"
|
||||
class="w-full h-20 bg-purple-600 hover:bg-purple-500 text-white font-black uppercase tracking-widest rounded-xl transition-all duration-200 flex flex-col items-center justify-center overflow-hidden relative cursor-pointer"
|
||||
>
|
||||
<span
|
||||
class="relative z-10 text-2xl"
|
||||
data-i18n="matchmaking_button.login_required"
|
||||
></span>
|
||||
</button>
|
||||
|
||||
<!-- Logged In Button -->
|
||||
<button
|
||||
id="matchmaking-button"
|
||||
class="hidden w-full h-20 bg-purple-600 hover:bg-purple-500 text-white font-black uppercase tracking-widest rounded-xl transition-all duration-200 flex flex-col items-center justify-center group overflow-hidden relative"
|
||||
data-i18n-title="matchmaking_modal.title"
|
||||
>
|
||||
<span
|
||||
class="relative z-10 text-2xl"
|
||||
data-i18n="matchmaking_button.play_ranked"
|
||||
></span>
|
||||
<span
|
||||
class="relative z-10 text-xs font-medium text-purple-100 opacity-90 group-hover:opacity-100 transition-opacity"
|
||||
data-i18n="matchmaking_button.description"
|
||||
></span>
|
||||
</button>
|
||||
<matchmaking-button></matchmaking-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
|
||||
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 rampCheckInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private rampWaitTimeout: 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();
|
||||
// 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;
|
||||
}
|
||||
if (this.rampCheckInterval) {
|
||||
clearInterval(this.rampCheckInterval);
|
||||
this.rampCheckInterval = null;
|
||||
}
|
||||
if (this.rampWaitTimeout) {
|
||||
clearTimeout(this.rampWaitTimeout);
|
||||
this.rampWaitTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
public showVideoAd(): void {
|
||||
if (!window.ramp) {
|
||||
// Wait for ramp to be available, but give up after timeout
|
||||
this.rampCheckInterval = setInterval(() => {
|
||||
if (window.ramp && window.ramp.que) {
|
||||
if (this.rampCheckInterval) {
|
||||
clearInterval(this.rampCheckInterval);
|
||||
this.rampCheckInterval = null;
|
||||
}
|
||||
if (this.rampWaitTimeout) {
|
||||
clearTimeout(this.rampWaitTimeout);
|
||||
this.rampWaitTimeout = null;
|
||||
}
|
||||
this.loadVideoAd();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Stop polling after timeout (e.g. adblocker preventing ramp from loading)
|
||||
this.rampWaitTimeout = setTimeout(() => {
|
||||
if (this.rampCheckInterval) {
|
||||
clearInterval(this.rampCheckInterval);
|
||||
this.rampCheckInterval = null;
|
||||
}
|
||||
console.log("[VideoAd] Ramp SDK never loaded - possible adblocker");
|
||||
this.handleAdBlocked();
|
||||
}, VideoAd.AD_LOAD_TIMEOUT_MS);
|
||||
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, chaining any existing handler
|
||||
const prevOnPlayerReady = window.ramp.onPlayerReady;
|
||||
window.ramp.onPlayerReady = () => {
|
||||
if (prevOnPlayerReady) prevOnPlayerReady();
|
||||
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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,14 @@ import {
|
||||
GOLD_INDEX_TRAIN_OTHER,
|
||||
GOLD_INDEX_TRAIN_SELF,
|
||||
GOLD_INDEX_WAR,
|
||||
PLAYER_INDEX_BOT,
|
||||
PLAYER_INDEX_HUMAN,
|
||||
PLAYER_INDEX_NATION,
|
||||
} from "../../../../core/StatsSchemas";
|
||||
|
||||
export enum RankType {
|
||||
Conquests = "Conquests",
|
||||
ConquestHumans = "ConquestHumans",
|
||||
ConquestBots = "ConquestBots",
|
||||
Atoms = "Atoms",
|
||||
Hydros = "Hydros",
|
||||
MIRV = "MIRV",
|
||||
@@ -27,7 +31,7 @@ export interface PlayerInfo {
|
||||
tag?: string;
|
||||
killedAt?: number;
|
||||
gold: bigint[];
|
||||
conquests: number;
|
||||
conquests: bigint[];
|
||||
flag?: string;
|
||||
winner: boolean;
|
||||
atoms: number;
|
||||
@@ -79,12 +83,13 @@ export class Ranking {
|
||||
username = match[2];
|
||||
}
|
||||
const gold = (stats.gold ?? []).map((v) => BigInt(v ?? 0));
|
||||
const conquests = (stats.conquests ?? []).map((v) => BigInt(v ?? 0));
|
||||
players[player.clientID] = {
|
||||
id: player.clientID,
|
||||
rawUsername: player.username,
|
||||
username,
|
||||
tag: player.clanTag,
|
||||
conquests: Number(stats.conquests) || 0,
|
||||
conquests,
|
||||
flag: player.cosmetics?.flag ?? undefined,
|
||||
killedAt: stats.killedAt !== null ? Number(stats.killedAt) : undefined,
|
||||
gold,
|
||||
@@ -125,8 +130,13 @@ export class Ranking {
|
||||
return (player.killedAt / Math.max(this.duration, 1)) * 10;
|
||||
}
|
||||
return 100;
|
||||
case RankType.Conquests:
|
||||
return player.conquests;
|
||||
case RankType.ConquestHumans:
|
||||
return Number(player.conquests[PLAYER_INDEX_HUMAN] ?? 0n);
|
||||
case RankType.ConquestBots:
|
||||
return (
|
||||
Number(player.conquests[PLAYER_INDEX_BOT] ?? 0n) +
|
||||
Number(player.conquests[PLAYER_INDEX_NATION] ?? 0n)
|
||||
);
|
||||
case RankType.Atoms:
|
||||
return player.atoms;
|
||||
case RankType.Hydros:
|
||||
|
||||
@@ -63,7 +63,8 @@ export class PlayerRow extends LitElement {
|
||||
private renderPlayerInfo() {
|
||||
switch (this.rankType) {
|
||||
case RankType.Lifetime:
|
||||
case RankType.Conquests:
|
||||
case RankType.ConquestHumans:
|
||||
case RankType.ConquestBots:
|
||||
return this.renderScoreAsBar();
|
||||
case RankType.Atoms:
|
||||
case RankType.Hydros:
|
||||
|
||||
@@ -10,19 +10,25 @@ const economyRankings = new Set([
|
||||
RankType.NavalTrade,
|
||||
RankType.TrainTrade,
|
||||
]);
|
||||
const tradeRankings = new Set([RankType.NavalTrade, RankType.TrainTrade]);
|
||||
const bombRankings = new Set([RankType.Atoms, RankType.Hydros, RankType.MIRV]);
|
||||
const warRankings = new Set([
|
||||
RankType.Conquests,
|
||||
RankType.ConquestHumans,
|
||||
RankType.ConquestBots,
|
||||
RankType.Atoms,
|
||||
RankType.Hydros,
|
||||
RankType.MIRV,
|
||||
]);
|
||||
const tradeRankings = new Set([RankType.NavalTrade, RankType.TrainTrade]);
|
||||
const bombRankings = new Set([RankType.Atoms, RankType.Hydros, RankType.MIRV]);
|
||||
const conquestRankings = new Set([
|
||||
RankType.ConquestHumans,
|
||||
RankType.ConquestBots,
|
||||
]);
|
||||
|
||||
const isEconomyRanking = (t: RankType) => economyRankings.has(t);
|
||||
const isTradeRanking = (t: RankType) => tradeRankings.has(t);
|
||||
const isBombRanking = (t: RankType) => bombRankings.has(t);
|
||||
const isWarRanking = (t: RankType) => warRankings.has(t);
|
||||
const isConquestRanking = (t: RankType) => conquestRankings.has(t);
|
||||
|
||||
@customElement("ranking-controls")
|
||||
export class RankingControls extends LitElement {
|
||||
@@ -41,7 +47,7 @@ export class RankingControls extends LitElement {
|
||||
"game_info_modal.duration",
|
||||
)}
|
||||
${this.renderButton(
|
||||
RankType.Conquests,
|
||||
RankType.ConquestHumans,
|
||||
isWarRanking(this.rankType),
|
||||
"game_info_modal.war",
|
||||
)}
|
||||
@@ -78,8 +84,8 @@ export class RankingControls extends LitElement {
|
||||
"game_info_modal.bombs",
|
||||
)}
|
||||
${this.renderSubButton(
|
||||
RankType.Conquests,
|
||||
this.rankType === RankType.Conquests,
|
||||
RankType.ConquestHumans,
|
||||
isConquestRanking(this.rankType),
|
||||
"game_info_modal.conquests",
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ export class RankingHeader extends LitElement {
|
||||
render() {
|
||||
return html`
|
||||
<li
|
||||
class="text-lg border-white/5 bg-white/[0.02] text-white/60 text-xs uppercase tracking-wider relative pt-2 pb-2 pr-5 pl-5 flex justify-between items-center"
|
||||
class="h-[30px] text-lg border-white/5 bg-white/[0.02] text-white/60 text-xs uppercase tracking-wider relative pt-2 pb-2 pr-5 pl-5 flex justify-between items-center"
|
||||
>
|
||||
${this.renderHeaderContent()}
|
||||
</li>
|
||||
@@ -27,10 +27,21 @@ export class RankingHeader extends LitElement {
|
||||
return html`<div class="w-full">
|
||||
${translateText("game_info_modal.survival_time")}
|
||||
</div>`;
|
||||
case RankType.Conquests:
|
||||
return html`<div class="w-full">
|
||||
${translateText("game_info_modal.num_of_conquests")}
|
||||
</div>`;
|
||||
case RankType.ConquestHumans:
|
||||
case RankType.ConquestBots:
|
||||
return html`
|
||||
<div class="flex justify-between sm:px-17.5 w-full">
|
||||
${this.renderMultipleChoiceHeaderButton(
|
||||
translateText("game_info_modal.num_of_conquests_humans"),
|
||||
RankType.ConquestHumans,
|
||||
)}
|
||||
/
|
||||
${this.renderMultipleChoiceHeaderButton(
|
||||
translateText("game_info_modal.num_of_conquests_bots"),
|
||||
RankType.ConquestBots,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
case RankType.Atoms:
|
||||
case RankType.Hydros:
|
||||
case RankType.MIRV:
|
||||
|
||||
@@ -149,7 +149,7 @@ export class PlayerStatsTreeView extends LitElement {
|
||||
attacks: this.mergeStatArrays(base.attacks, next.attacks),
|
||||
betrayals: this.mergeStatValue(base.betrayals, next.betrayals),
|
||||
killedAt: this.mergeStatValue(base.killedAt, next.killedAt),
|
||||
conquests: this.mergeStatValue(base.conquests, next.conquests),
|
||||
conquests: this.mergeStatArrays(base.conquests, next.conquests),
|
||||
boats: this.mergeStatRecord(base.boats, next.boats),
|
||||
bombs: this.mergeStatRecord(base.bombs, next.bombs),
|
||||
gold: this.mergeStatArrays(base.gold, next.gold),
|
||||
@@ -203,7 +203,7 @@ export class PlayerStatsTreeView extends LitElement {
|
||||
attacks: stats.attacks ? [...stats.attacks] : undefined,
|
||||
betrayals: stats.betrayals,
|
||||
killedAt: stats.killedAt,
|
||||
conquests: stats.conquests,
|
||||
conquests: stats.conquests ? [...stats.conquests] : undefined,
|
||||
boats: stats.boats ? { ...stats.boats } : undefined,
|
||||
bombs: stats.bombs ? { ...stats.bombs } : undefined,
|
||||
gold: stats.gold ? [...stats.gold] : undefined,
|
||||
|
||||
@@ -6,7 +6,6 @@ import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler";
|
||||
import { FrameProfiler } from "./FrameProfiler";
|
||||
import { TransformHandler } from "./TransformHandler";
|
||||
import { UIState } from "./UIState";
|
||||
import { AdTimer } from "./layers/AdTimer";
|
||||
import { AlertFrame } from "./layers/AlertFrame";
|
||||
import { BuildMenu } from "./layers/BuildMenu";
|
||||
import { ChatDisplay } from "./layers/ChatDisplay";
|
||||
@@ -20,6 +19,7 @@ import { GameLeftSidebar } from "./layers/GameLeftSidebar";
|
||||
import { GameRightSidebar } from "./layers/GameRightSidebar";
|
||||
import { HeadsUpMessage } from "./layers/HeadsUpMessage";
|
||||
import { ImmunityTimer } from "./layers/ImmunityTimer";
|
||||
import { InGameHeaderAd } from "./layers/InGameHeaderAd";
|
||||
import { Layer } from "./layers/Layer";
|
||||
import { Leaderboard } from "./layers/Leaderboard";
|
||||
import { MainRadialMenu } from "./layers/MainRadialMenu";
|
||||
@@ -34,6 +34,7 @@ import { ReplayPanel } from "./layers/ReplayPanel";
|
||||
import { SAMRadiusLayer } from "./layers/SAMRadiusLayer";
|
||||
import { SettingsModal } from "./layers/SettingsModal";
|
||||
import { SpawnTimer } from "./layers/SpawnTimer";
|
||||
import { SpawnVideoAd } from "./layers/SpawnVideoAd";
|
||||
import { StructureIconsLayer } from "./layers/StructureIconsLayer";
|
||||
import { StructureLayer } from "./layers/StructureLayer";
|
||||
import { TeamStats } from "./layers/TeamStats";
|
||||
@@ -244,6 +245,20 @@ export function createRenderer(
|
||||
}
|
||||
immunityTimer.game = game;
|
||||
|
||||
const inGameHeaderAd = document.querySelector(
|
||||
"in-game-header-ad",
|
||||
) as InGameHeaderAd;
|
||||
if (!(inGameHeaderAd instanceof InGameHeaderAd)) {
|
||||
console.error("in-game header ad not found");
|
||||
}
|
||||
inGameHeaderAd.game = game;
|
||||
|
||||
const spawnVideoAd = document.querySelector("spawn-video-ad") as SpawnVideoAd;
|
||||
if (!(spawnVideoAd instanceof SpawnVideoAd)) {
|
||||
console.error("spawn video ad not found");
|
||||
}
|
||||
spawnVideoAd.game = game;
|
||||
|
||||
// When updating these layers please be mindful of the order.
|
||||
// Try to group layers by the return value of shouldTransform.
|
||||
// Not grouping the layers may cause excessive calls to context.save() and context.restore().
|
||||
@@ -287,7 +302,8 @@ export function createRenderer(
|
||||
playerPanel,
|
||||
headsUpMessage,
|
||||
multiTabModal,
|
||||
new AdTimer(game),
|
||||
inGameHeaderAd,
|
||||
spawnVideoAd,
|
||||
alertFrame,
|
||||
performanceOverlay,
|
||||
];
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
const AD_SHOW_TICKS = 2 * 60 * 10; // 2 minute
|
||||
|
||||
export class AdTimer implements Layer {
|
||||
private isHidden: boolean = false;
|
||||
|
||||
constructor(private g: GameView) {}
|
||||
|
||||
init() {}
|
||||
|
||||
public async tick() {
|
||||
if (this.isHidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
const gameTicks = this.g.ticks() - this.g.config().numSpawnPhaseTurns();
|
||||
if (gameTicks > AD_SHOW_TICKS) {
|
||||
console.log("destroying sticky ads");
|
||||
window.fusetag?.que?.push(() => {
|
||||
window.fusetag?.destroySticky?.();
|
||||
});
|
||||
this.isHidden = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,6 +142,9 @@ export class ChatModal extends LitElement {
|
||||
player
|
||||
? "selected"
|
||||
: ""}"
|
||||
style="border: 2px solid ${player
|
||||
.territoryColor()
|
||||
.toHex()};"
|
||||
@click=${() => this.selectPlayer(player)}
|
||||
>
|
||||
${player.name()}
|
||||
|
||||
@@ -114,10 +114,15 @@ export class GameRightSidebar extends LitElement implements Layer {
|
||||
|
||||
private onPauseButtonClick() {
|
||||
this.isPaused = !this.isPaused;
|
||||
if (this.isPaused) {
|
||||
crazyGamesSDK.gameplayStop();
|
||||
} else {
|
||||
crazyGamesSDK.gameplayStart();
|
||||
}
|
||||
this.eventBus.emit(new PauseGameIntentEvent(this.isPaused));
|
||||
}
|
||||
|
||||
private onExitButtonClick() {
|
||||
private async onExitButtonClick() {
|
||||
const isAlive = this.game.myPlayer()?.isAlive();
|
||||
if (isAlive) {
|
||||
const isConfirmed = confirm(
|
||||
@@ -125,10 +130,10 @@ export class GameRightSidebar extends LitElement implements Layer {
|
||||
);
|
||||
if (!isConfirmed) return;
|
||||
}
|
||||
crazyGamesSDK.gameplayStop().then(() => {
|
||||
// redirect to the home page
|
||||
window.location.href = "/";
|
||||
});
|
||||
await crazyGamesSDK.requestMidgameAd();
|
||||
await crazyGamesSDK.gameplayStop();
|
||||
// redirect to the home page
|
||||
window.location.href = "/";
|
||||
}
|
||||
|
||||
private onSettingsButtonClick() {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
const AD_SHOW_TICKS = 2 * 60 * 10; // 2 minutes
|
||||
const HEADER_AD_TYPE = "standard_iab_head1";
|
||||
const HEADER_AD_CONTAINER_ID = "header-ad-container";
|
||||
const TWO_XL_BREAKPOINT = 1536;
|
||||
|
||||
@customElement("in-game-header-ad")
|
||||
export class InGameHeaderAd extends LitElement implements Layer {
|
||||
public game: GameView;
|
||||
|
||||
private isHidden: boolean = false;
|
||||
private adLoaded: boolean = false;
|
||||
private shouldShow: boolean = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.showHeaderAd();
|
||||
}
|
||||
|
||||
private showHeaderAd(): void {
|
||||
// Don't show header ad on screens smaller than 2xl
|
||||
if (window.innerWidth < TWO_XL_BREAKPOINT) {
|
||||
return;
|
||||
}
|
||||
if (!window.adsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.shouldShow = true;
|
||||
this.requestUpdate();
|
||||
|
||||
// Wait for the element to render before loading the ad
|
||||
this.updateComplete.then(() => {
|
||||
this.loadAd();
|
||||
});
|
||||
}
|
||||
|
||||
private loadAd(): void {
|
||||
if (!window.ramp) {
|
||||
console.warn("Playwire RAMP not available for header ad");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.ramp.que.push(() => {
|
||||
try {
|
||||
window.ramp.spaAddAds([
|
||||
{
|
||||
type: HEADER_AD_TYPE,
|
||||
selectorId: HEADER_AD_CONTAINER_ID,
|
||||
},
|
||||
]);
|
||||
this.adLoaded = true;
|
||||
console.log("Header ad loaded:", HEADER_AD_TYPE);
|
||||
} catch (e) {
|
||||
console.error("Failed to add header ad:", e);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to load header ad:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private hideHeaderAd(): void {
|
||||
this.shouldShow = false;
|
||||
this.adLoaded = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
public tick() {
|
||||
if (this.isHidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
const gameTicks =
|
||||
this.game.ticks() - this.game.config().numSpawnPhaseTurns();
|
||||
if (gameTicks > AD_SHOW_TICKS) {
|
||||
console.log("destroying header ad and refreshing PageOS");
|
||||
this.hideHeaderAd();
|
||||
this.isHidden = true;
|
||||
|
||||
if (window.PageOS?.session?.newPageView) {
|
||||
window.PageOS.session.newPageView();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.shouldShow) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
id="${HEADER_AD_CONTAINER_ID}"
|
||||
class="hidden 2xl:flex fixed top-0 left-1/2 -translate-x-1/2 z-[100] justify-center items-center pointer-events-auto p-0 -mt-[20px]"
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -504,6 +504,14 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
${unit.type() === UnitType.TransportShip
|
||||
? html`
|
||||
<div class="text-sm">
|
||||
${translateText("player_info_overlay.troops")}:
|
||||
${renderTroops(unit.troops())}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -17,7 +17,6 @@ import allianceIcon from "/images/AllianceIconWhite.svg?url";
|
||||
import boatIcon from "/images/BoatIconWhite.svg?url";
|
||||
import buildIcon from "/images/BuildIconWhite.svg?url";
|
||||
import chatIcon from "/images/ChatIconWhite.svg?url";
|
||||
import checkmarkIcon from "/images/CheckmarkIconWhite.svg?url";
|
||||
import donateGoldIcon from "/images/DonateGoldIconWhite.svg?url";
|
||||
import donateTroopIcon from "/images/DonateTroopIconWhite.svg?url";
|
||||
import emojiIcon from "/images/EmojiIconWhite.svg?url";
|
||||
@@ -219,15 +218,6 @@ const allyBreakElement: MenuElement = {
|
||||
!!params.playerActions?.interaction?.canBreakAlliance,
|
||||
color: COLORS.breakAlly,
|
||||
icon: traitorIcon,
|
||||
subMenu: () => [allyBreakCancelElement, allyBreakConfirmElement],
|
||||
};
|
||||
|
||||
const allyBreakConfirmElement: MenuElement = {
|
||||
id: "ally_break_confirm",
|
||||
name: "confirm",
|
||||
disabled: () => false,
|
||||
color: COLORS.breakAlly,
|
||||
icon: checkmarkIcon,
|
||||
action: (params: MenuElementParams) => {
|
||||
params.playerActionHandler.handleBreakAlliance(
|
||||
params.myPlayer,
|
||||
@@ -237,17 +227,6 @@ const allyBreakConfirmElement: MenuElement = {
|
||||
},
|
||||
};
|
||||
|
||||
const allyBreakCancelElement: MenuElement = {
|
||||
id: "ally_break_cancel",
|
||||
name: "cancel",
|
||||
disabled: () => false,
|
||||
color: COLORS.info,
|
||||
icon: xIcon,
|
||||
action: (params: MenuElementParams) => {
|
||||
params.closeMenu();
|
||||
},
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const allyDonateGoldElement: MenuElement = {
|
||||
id: "ally_donate_gold",
|
||||
@@ -638,10 +617,7 @@ export const rootMenuElement: MenuElement = {
|
||||
icon: infoIcon,
|
||||
color: COLORS.info,
|
||||
subMenu: (params: MenuElementParams) => {
|
||||
let ally = allyRequestElement;
|
||||
if (params.selected?.isAlliedWith(params.myPlayer)) {
|
||||
ally = allyBreakElement;
|
||||
}
|
||||
const isAllied = params.selected?.isAlliedWith(params.myPlayer);
|
||||
|
||||
const tileOwner = params.game.owner(params.tile);
|
||||
const isOwnTerritory =
|
||||
@@ -651,10 +627,10 @@ export const rootMenuElement: MenuElement = {
|
||||
const menuItems: (MenuElement | null)[] = [
|
||||
infoMenuElement,
|
||||
...(isOwnTerritory
|
||||
? [deleteUnitElement, ally, buildMenuElement]
|
||||
? [deleteUnitElement, allyRequestElement, buildMenuElement]
|
||||
: [
|
||||
boatMenuElement,
|
||||
ally,
|
||||
isAllied ? allyBreakElement : boatMenuElement,
|
||||
allyRequestElement,
|
||||
isFriendlyTarget(params) && !isDisconnectedTarget(params)
|
||||
? donateGoldRadialElement
|
||||
: attackMenuElement,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import { crazyGamesSDK } from "src/client/CrazyGamesSDK";
|
||||
import { PauseGameIntentEvent } from "src/client/Transport";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { UserSettings } from "../../../core/game/UserSettings";
|
||||
import { AlternateViewEvent, RefreshGraphicsEvent } from "../../InputHandler";
|
||||
import { PauseGameIntentEvent } from "../../Transport";
|
||||
import { translateText } from "../../Utils";
|
||||
import SoundManager from "../../sound/SoundManager";
|
||||
import { Layer } from "./Layer";
|
||||
@@ -105,8 +106,14 @@ export class SettingsModal extends LitElement implements Layer {
|
||||
}
|
||||
|
||||
private pauseGame(pause: boolean) {
|
||||
if (this.shouldPause && !this.wasPausedWhenOpened)
|
||||
if (this.shouldPause && !this.wasPausedWhenOpened) {
|
||||
if (pause) {
|
||||
crazyGamesSDK.gameplayStop();
|
||||
} else {
|
||||
crazyGamesSDK.gameplayStart();
|
||||
}
|
||||
this.eventBus.emit(new PauseGameIntentEvent(pause));
|
||||
}
|
||||
}
|
||||
|
||||
private onTerrainButtonClick() {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
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/VideoAd";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
@customElement("spawn-video-ad")
|
||||
export class SpawnVideoAd extends LitElement implements Layer {
|
||||
public game: GameView;
|
||||
|
||||
@state() private shouldShow = false;
|
||||
@state() private adComplete = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
init() {
|
||||
if (
|
||||
!window.adsEnabled ||
|
||||
window.innerWidth < 768 ||
|
||||
crazyGamesSDK.isOnCrazyGames() ||
|
||||
this.game.config().gameConfig().gameType === GameType.Singleplayer ||
|
||||
getGamesPlayed() < 3 || // Don't show to new players
|
||||
getGamesPlayed() % 3 !== 0 // Only show 1 in 3 times
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.shouldShow = true;
|
||||
}
|
||||
|
||||
tick() {
|
||||
if (this.adComplete) return;
|
||||
// Hide when spawn phase ends
|
||||
if (this.shouldShow && !this.game.inSpawnPhase()) {
|
||||
this.shouldShow = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private handleComplete = () => {
|
||||
this.adComplete = true;
|
||||
this.shouldShow = false;
|
||||
};
|
||||
|
||||
shouldTransform(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.shouldShow || this.adComplete) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="fixed bottom-0 left-0 z-[9999] pointer-events-auto">
|
||||
<video-ad
|
||||
style="width: 400px; max-width: 400px; height: 225px; aspect-ratio: auto;"
|
||||
.onComplete="${this.handleComplete}"
|
||||
></video-ad>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -130,7 +130,7 @@ export class UnitDisplay extends LitElement implements Layer {
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="hidden 2xl:flex lg:flex fixed bottom-4 left-1/2 transform -translate-x-1/2 z-1100 2xl:flex-row xl:flex-col lg:flex-col 2xl:gap-5 xl:gap-2 lg:gap-2 justify-center items-center"
|
||||
class="hidden min-[1200px]:flex fixed bottom-4 left-1/2 transform -translate-x-1/2 z-[1100] 2xl:flex-row xl:flex-col min-[1200px]:flex-col 2xl:gap-5 xl:gap-2 min-[1200px]:gap-2 justify-center items-center"
|
||||
>
|
||||
<div class="bg-gray-800/70 backdrop-blur-xs rounded-lg p-0.5">
|
||||
<div class="grid grid-rows-1 auto-cols-max grid-flow-col gap-1 w-fit">
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { LitElement, TemplateResult, html } from "lit";
|
||||
import { html, LitElement, TemplateResult } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import {
|
||||
getGamesPlayed,
|
||||
isInIframe,
|
||||
translateText,
|
||||
TUTORIAL_VIDEO_URL,
|
||||
} from "../../../client/Utils";
|
||||
import { ColorPalette, Pattern } from "../../../core/CosmeticSchemas";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
@@ -131,9 +132,7 @@ export class WinModal extends LitElement implements Layer {
|
||||
<div class="relative w-full pb-[56.25%]">
|
||||
<iframe
|
||||
class="absolute top-0 left-0 w-full h-full rounded-sm"
|
||||
src="${this.isVisible
|
||||
? "https://www.youtube.com/embed/EN2oOog3pSs"
|
||||
: ""}"
|
||||
src="${this.isVisible ? TUTORIAL_VIDEO_URL : ""}"
|
||||
title="YouTube video player"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
@@ -250,6 +249,7 @@ export class WinModal extends LitElement implements Layer {
|
||||
}
|
||||
|
||||
async show() {
|
||||
crazyGamesSDK.gameplayStop();
|
||||
await this.loadPatternContent();
|
||||
this.isVisible = true;
|
||||
this.requestUpdate();
|
||||
|
||||
+1
-1
@@ -248,7 +248,7 @@ export const AllPlayersStatsSchema = z.record(ID, PlayerStatsSchema);
|
||||
|
||||
export const UsernameSchema = z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9_ [\]üÜ]+$/u)
|
||||
.regex(/^[a-zA-Z0-9_ [\]üÜ.]+$/u)
|
||||
.min(3)
|
||||
.max(27);
|
||||
const countryCodes = countries.filter((c) => !c.restricted).map((c) => c.code);
|
||||
|
||||
@@ -62,6 +62,11 @@ export const ATTACK_INDEX_SENT = 0; // Outgoing attack troops
|
||||
export const ATTACK_INDEX_RECV = 1; // Incmoing attack troops
|
||||
export const ATTACK_INDEX_CANCEL = 2; // Cancelled attack troops
|
||||
|
||||
// Player types
|
||||
export const PLAYER_INDEX_HUMAN = 0;
|
||||
export const PLAYER_INDEX_NATION = 1;
|
||||
export const PLAYER_INDEX_BOT = 2;
|
||||
|
||||
// Boats
|
||||
export const BOAT_INDEX_SENT = 0; // Boats launched
|
||||
export const BOAT_INDEX_ARRIVE = 1; // Boats arrived
|
||||
@@ -102,7 +107,7 @@ export const PlayerStatsSchema = z
|
||||
attacks: AtLeastOneNumberSchema.optional(),
|
||||
betrayals: BigIntStringSchema.optional(),
|
||||
killedAt: BigIntStringSchema.optional(),
|
||||
conquests: BigIntStringSchema.optional(),
|
||||
conquests: AtLeastOneNumberSchema.optional(),
|
||||
boats: z.partialRecord(BoatUnitSchema, AtLeastOneNumberSchema).optional(),
|
||||
bombs: z.partialRecord(BombUnitSchema, AtLeastOneNumberSchema).optional(),
|
||||
gold: AtLeastOneNumberSchema.optional(),
|
||||
|
||||
@@ -734,7 +734,7 @@ export class DefaultConfig implements Config {
|
||||
if (playerInfo.playerType === PlayerType.Nation) {
|
||||
switch (this._gameConfig.difficulty) {
|
||||
case Difficulty.Easy:
|
||||
return 18_750;
|
||||
return 12_500;
|
||||
case Difficulty.Medium:
|
||||
return 25_000; // Like humans
|
||||
case Difficulty.Hard:
|
||||
@@ -769,7 +769,7 @@ export class DefaultConfig implements Config {
|
||||
|
||||
switch (this._gameConfig.difficulty) {
|
||||
case Difficulty.Easy:
|
||||
return maxTroops * 0.75;
|
||||
return maxTroops * 0.5;
|
||||
case Difficulty.Medium:
|
||||
return maxTroops * 1; // Like humans
|
||||
case Difficulty.Hard:
|
||||
@@ -796,7 +796,7 @@ export class DefaultConfig implements Config {
|
||||
if (player.type() === PlayerType.Nation) {
|
||||
switch (this._gameConfig.difficulty) {
|
||||
case Difficulty.Easy:
|
||||
toAdd *= 0.95;
|
||||
toAdd *= 0.9;
|
||||
break;
|
||||
case Difficulty.Medium:
|
||||
toAdd *= 1; // Like humans
|
||||
|
||||
@@ -114,7 +114,7 @@ export class TransportShipExecution implements Execution {
|
||||
mg.displayIncomingUnit(
|
||||
this.boat.id(),
|
||||
// TODO TranslateText
|
||||
`Naval invasion incoming from ${this.attacker.displayName()}`,
|
||||
`Naval invasion incoming from ${this.attacker.displayName()} (${renderTroops(this.boat.troops())})`,
|
||||
MessageType.NAVAL_INVASION_INBOUND,
|
||||
this.target.id(),
|
||||
);
|
||||
|
||||
@@ -51,6 +51,7 @@ export class FetchGameMapLoader implements GameMapLoader {
|
||||
}
|
||||
|
||||
private async loadBinaryFromUrl(url: string) {
|
||||
const startTime = performance.now();
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -58,6 +59,9 @@ export class FetchGameMapLoader implements GameMapLoader {
|
||||
}
|
||||
|
||||
const data = await response.arrayBuffer();
|
||||
console.log(
|
||||
`[MapLoader] ${url}: ${(performance.now() - startTime).toFixed(0)}ms`,
|
||||
);
|
||||
return new Uint8Array(data);
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ export enum GameMapType {
|
||||
Manicouagan = "Manicouagan",
|
||||
Lemnos = "Lemnos",
|
||||
Sierpinski = "Sierpinski",
|
||||
TheBox = "The Box",
|
||||
TwoLakes = "Two Lakes",
|
||||
StraitOfHormuz = "Strait of Hormuz",
|
||||
Surrounded = "Surrounded",
|
||||
@@ -177,6 +178,7 @@ export const mapCategories: Record<string, GameMapType[]> = {
|
||||
GameMapType.Surrounded,
|
||||
],
|
||||
arcade: [
|
||||
GameMapType.TheBox,
|
||||
GameMapType.Didier,
|
||||
GameMapType.DidierFrance,
|
||||
GameMapType.Sierpinski,
|
||||
|
||||
@@ -104,6 +104,8 @@ export class GameImpl implements Game {
|
||||
private _config: Config,
|
||||
private _stats: Stats,
|
||||
) {
|
||||
const constructorStart = performance.now();
|
||||
|
||||
this._terraNullius = new TerraNulliusImpl();
|
||||
this._width = _map.width();
|
||||
this._height = _map.height();
|
||||
@@ -124,6 +126,10 @@ export class GameImpl implements Game {
|
||||
{ cachePaths: true },
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[GameImpl] Constructor total: ${(performance.now() - constructorStart).toFixed(0)}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
private populateTeams() {
|
||||
|
||||
@@ -24,11 +24,14 @@ import {
|
||||
OTHER_INDEX_LOST,
|
||||
OTHER_INDEX_UPGRADE,
|
||||
OtherUnitType,
|
||||
PLAYER_INDEX_BOT,
|
||||
PLAYER_INDEX_HUMAN,
|
||||
PLAYER_INDEX_NATION,
|
||||
PlayerStats,
|
||||
unitTypeToBombUnit,
|
||||
unitTypeToOtherUnit,
|
||||
} from "../StatsSchemas";
|
||||
import { Player, TerraNullius, UnitType } from "./Game";
|
||||
import { Player, PlayerType, TerraNullius, UnitType } from "./Game";
|
||||
import { Stats } from "./Stats";
|
||||
|
||||
type BigIntLike = bigint | number;
|
||||
@@ -41,6 +44,12 @@ function _bigint(value: BigIntLike): bigint {
|
||||
}
|
||||
}
|
||||
|
||||
const conquest_by_type: Record<PlayerType, number> = {
|
||||
[PlayerType.Human]: PLAYER_INDEX_HUMAN,
|
||||
[PlayerType.Nation]: PLAYER_INDEX_NATION,
|
||||
[PlayerType.Bot]: PLAYER_INDEX_BOT,
|
||||
};
|
||||
|
||||
export class StatsImpl implements Stats {
|
||||
private readonly data: AllPlayersStats = {};
|
||||
|
||||
@@ -138,14 +147,12 @@ export class StatsImpl implements Stats {
|
||||
p.units[type][index] += _bigint(value);
|
||||
}
|
||||
|
||||
private _addConquest(player: Player) {
|
||||
private _addConquest(player: Player, index: number) {
|
||||
const p = this._makePlayerStats(player);
|
||||
if (p === undefined) return;
|
||||
if (p.conquests === undefined) {
|
||||
p.conquests = _bigint(1);
|
||||
} else {
|
||||
p.conquests += _bigint(1);
|
||||
}
|
||||
p.conquests ??= [0n];
|
||||
while (p.conquests.length <= index) p.conquests.push(0n);
|
||||
p.conquests[index] += _bigint(1);
|
||||
}
|
||||
|
||||
private _addPlayerKilled(player: Player, tick: number) {
|
||||
@@ -249,7 +256,10 @@ export class StatsImpl implements Stats {
|
||||
|
||||
goldWar(player: Player, captured: Player, gold: BigIntLike): void {
|
||||
this._addGold(player, GOLD_INDEX_WAR, gold);
|
||||
this._addConquest(player);
|
||||
const conquestType = conquest_by_type[captured.type()];
|
||||
if (conquestType !== undefined) {
|
||||
this._addConquest(player, conquestType);
|
||||
}
|
||||
}
|
||||
|
||||
unitBuild(player: Player, type: OtherUnitType): void {
|
||||
|
||||
@@ -80,7 +80,7 @@ export class WorkerClient {
|
||||
this.messageHandlers.delete(messageId);
|
||||
reject(new Error("Worker initialization timeout"));
|
||||
}
|
||||
}, 5000); // 5 second timeout
|
||||
}, 20000); // 20 second timeout
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -293,17 +293,16 @@ export class GameServer {
|
||||
const parsed = ClientMessageSchema.safeParse(JSON.parse(message));
|
||||
if (!parsed.success) {
|
||||
const error = z.prettifyError(parsed.error);
|
||||
this.log.error("Failed to parse client message", error, {
|
||||
this.log.warn(`Failed to parse client message ${error}`, {
|
||||
clientID: client.clientID,
|
||||
});
|
||||
client.ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error,
|
||||
message,
|
||||
message: `Server could not parse message from client: ${message}`,
|
||||
} satisfies ServerErrorMessage),
|
||||
);
|
||||
client.ws.close(1002, "ClientMessageSchema");
|
||||
return;
|
||||
}
|
||||
const clientMsg = parsed.data;
|
||||
|
||||
@@ -62,8 +62,10 @@ const frequency: Partial<Record<GameMapName, number>> = {
|
||||
StraitOfHormuz: 4,
|
||||
Surrounded: 4,
|
||||
DidierFrance: 1,
|
||||
Didier: 1,
|
||||
AmazonRiver: 3,
|
||||
Sierpinski: 10,
|
||||
TheBox: 3,
|
||||
};
|
||||
|
||||
interface MapWithMode {
|
||||
|
||||
@@ -531,7 +531,7 @@ async function startMatchmakingPolling(gm: GameManager) {
|
||||
// Wait a few seconds to allow clients to connect.
|
||||
console.log(`Starting game ${gameId}`);
|
||||
game.start();
|
||||
}, 5000);
|
||||
}, 7000);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`Error polling lobby:`, error);
|
||||
|
||||
Reference in New Issue
Block a user