Merge branch 'main' into embeddedurlfix

This commit is contained in:
Ryan
2026-02-01 12:17:01 +00:00
committed by GitHub
73 changed files with 3648 additions and 885 deletions
+34 -26
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 = "";
}
}
}
+9 -1
View File
@@ -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() {
+1
View File
@@ -228,6 +228,7 @@ export class LangSelector extends LitElement {
"stats-modal",
"flag-input-modal",
"flag-input",
"matchmaking-button",
"token-login",
];
+79 -92
View File
@@ -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
View File
@@ -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() {
+6 -15
View File
@@ -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");
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+10
View File
@@ -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();
+9 -2
View File
@@ -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),
+77 -13
View File
@@ -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"
+12 -6
View File
@@ -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>
+1 -1
View File
@@ -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"}"
+1 -26
View File
@@ -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>
+213
View File
@@ -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,
+18 -2
View File
@@ -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,
];
-28
View File
@@ -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;
}
}
}
+3
View File
@@ -142,6 +142,9 @@ export class ChatModal extends LitElement {
player
? "selected"
: ""}"
style="border: 2px solid ${player
.territoryColor()
.toHex()};"
@click=${() => this.selectPlayer(player)}
>
${player.name()}
+10 -5
View File
@@ -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,
+9 -2
View File
@@ -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>
`;
}
}
+1 -1
View File
@@ -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">
+4 -4
View File
@@ -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
View File
@@ -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);
+6 -1
View File
@@ -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(),
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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(),
);
+4
View File
@@ -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);
}
+2
View File
@@ -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,
+6
View File
@@ -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() {
+18 -8
View File
@@ -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 {
+1 -1
View File
@@ -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
});
}
+2 -3
View File
@@ -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;
+2
View File
@@ -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 {
+1 -1
View File
@@ -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);