Merge branch 'main' into player-text-opacity

This commit is contained in:
bijx
2026-04-24 00:17:07 -04:00
committed by GitHub
343 changed files with 20725 additions and 4841 deletions
+18 -5
View File
@@ -6,7 +6,7 @@ import {
UserMeResponse,
} from "../core/ApiSchemas";
import { assetUrl } from "../core/AssetUrls";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { getRuntimeClientServerConfig } from "../core/configuration/ConfigLoader";
import { fetchPlayerById, getUserMe } from "./Api";
import { discordLogin, logOut, sendMagicLink } from "./Auth";
import "./components/baseComponents/stats/DiscordUserHeader";
@@ -15,8 +15,8 @@ import "./components/baseComponents/stats/PlayerStatsTable";
import "./components/baseComponents/stats/PlayerStatsTree";
import { BaseModal } from "./components/BaseModal";
import "./components/CopyButton";
import "./components/CurrencyDisplay";
import "./components/Difficulties";
import "./components/PatternButton";
import { modalHeader } from "./components/ui/ModalHeader";
import { translateText } from "./Utils";
@@ -192,12 +192,24 @@ export class AccountModal extends BaseModal {
`;
}
private renderCurrency(): TemplateResult {
const currency = this.userMeResponse?.player?.currency;
if (!currency) return html``;
return html`
<currency-display
.hard=${currency.hard}
.soft=${currency.soft}
></currency-display>
`;
}
private renderLoggedInAs(): TemplateResult {
const me = this.userMeResponse?.user;
if (me?.discord) {
return html`
<div class="flex flex-col items-center gap-3 w-full">
${this.renderLogoutButton()}
${this.renderCurrency()} ${this.renderLogoutButton()}
</div>
`;
} else if (me?.email) {
@@ -208,7 +220,7 @@ export class AccountModal extends BaseModal {
account_name: me.email,
})}
</div>
${this.renderLogoutButton()}
${this.renderCurrency()} ${this.renderLogoutButton()}
</div>
`;
}
@@ -217,7 +229,7 @@ export class AccountModal extends BaseModal {
private async viewGame(gameId: string): Promise<void> {
this.close();
const config = await getServerConfigFromClient();
const config = await getRuntimeClientServerConfig();
const encodedGameId = encodeURIComponent(gameId);
const newUrl = `/${config.workerPath(gameId)}/game/${encodedGameId}`;
@@ -266,6 +278,7 @@ export class AccountModal extends BaseModal {
<p class="text-white/50 text-sm font-medium">
${translateText("account_modal.sign_in_desc")}
</p>
${this.renderCurrency()}
</div>
<div class="space-y-6">
+69 -1
View File
@@ -1,7 +1,10 @@
import newsItemsFallback from "resources/news.json";
import { z } from "zod";
import type { NewsItem } from "../core/ApiSchemas";
import {
ClanLeaderboardResponse,
ClanLeaderboardResponseSchema,
NewsItemSchema,
PlayerProfile,
PlayerProfileSchema,
RankedLeaderboardResponse,
@@ -89,9 +92,52 @@ export async function getUserMe(): Promise<UserMeResponse | false> {
return __userMe;
}
export function invalidateUserMe() {
__userMe = null;
}
export async function purchaseWithCurrency(
cosmeticType: "pattern" | "skin" | "flag",
cosmeticName: string,
currencyType: "hard" | "soft",
colorPaletteName?: string,
): Promise<boolean> {
try {
const response = await fetch(`${getApiBase()}/shop/purchase`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: await getAuthHeader(),
},
body: JSON.stringify({
cosmeticType,
cosmeticName,
currencyType,
colorPaletteName,
}),
});
if (response.status === 401) {
await logOut();
return false;
}
if (!response.ok) {
console.error(
"purchaseWithCurrency: request failed",
response.status,
response.statusText,
);
return false;
}
return true;
} catch (e) {
console.error("purchaseWithCurrency: request failed", e);
return false;
}
}
export async function createCheckoutSession(
priceId: string,
colorPaletteName: string | null,
colorPaletteName?: string,
): Promise<string | false> {
try {
const response = await fetch(
@@ -263,3 +309,25 @@ export async function fetchPlayerLeaderboard(
return false;
}
}
export async function getNews(): Promise<NewsItem[]> {
try {
const res = await fetch(`${getApiBase()}/news.json`, {
headers: { Accept: "application/json" },
});
if (res.status !== 200) {
console.warn("getNews: unexpected status", res.status);
return newsItemsFallback as NewsItem[];
}
const json = await res.json();
const parsed = z.array(NewsItemSchema).safeParse(json);
if (!parsed.success) {
console.warn("getNews: Zod validation failed", parsed.error);
return newsItemsFallback as NewsItem[];
}
return parsed.data;
} catch (err) {
console.warn("getNews: request failed, using fallback", err);
return newsItemsFallback as NewsItem[];
}
}
+3
View File
@@ -1,4 +1,5 @@
import { decodeJwt } from "jose";
import { UserSettings } from "src/core/game/UserSettings";
import { z } from "zod";
import { TokenPayload, TokenPayloadSchema } from "../core/ApiSchemas";
import { base64urlToUuid } from "../core/Base64";
@@ -63,6 +64,8 @@ export async function logOut(allSessions: boolean = false): Promise<boolean> {
} finally {
__jwt = null;
localStorage.removeItem(PERSISTENT_ID_KEY);
new UserSettings().clearFlag();
new UserSettings().setSelectedPatternName(undefined);
}
}
+157 -30
View File
@@ -12,7 +12,7 @@ import {
} from "../core/Schemas";
import { createPartialGameRecord, findClosestBy, replacer } from "../core/Util";
import { ServerConfig } from "../core/configuration/Config";
import { getConfig } from "../core/configuration/ConfigLoader";
import { getGameLogicConfig } from "../core/configuration/ConfigLoader";
import { BuildableUnit, Structures, UnitType } from "../core/game/Game";
import { TileRef } from "../core/game/GameMap";
import { GameMapLoader } from "../core/game/GameMapLoader";
@@ -31,7 +31,9 @@ import { getPersistentID } from "./Auth";
import {
AutoUpgradeEvent,
DoBoatAttackEvent,
DoBreakAllianceEvent,
DoGroundAttackEvent,
DoRequestAllianceEvent,
InputHandler,
MouseMoveEvent,
MouseUpEvent,
@@ -40,8 +42,10 @@ import {
import { endGame, startGame, startTime } from "./LocalPersistantStats";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
import {
SendAllianceRequestIntentEvent,
SendAttackIntentEvent,
SendBoatAttackIntentEvent,
SendBreakAllianceIntentEvent,
SendHashEvent,
SendSpawnIntentEvent,
SendUpgradeStructureIntentEvent,
@@ -50,13 +54,14 @@ import {
import { createCanvas } from "./Utils";
import { createRenderer, GameRenderer } from "./graphics/GameRenderer";
import { GoToPlayerEvent } from "./graphics/layers/Leaderboard";
import SoundManager from "./sound/SoundManager";
import { SoundManager } from "./sound/SoundManager";
export interface LobbyConfig {
serverConfig: ServerConfig;
cosmetics: PlayerCosmeticRefs;
playerName: string;
playerClanTag: string | null;
playerRole: string | null;
gameID: GameID;
turnstileToken: string | null;
// GameStartInfo only exists when playing a singleplayer game.
@@ -172,6 +177,15 @@ export function joinLobby(
composed: true,
}),
);
} else if (message.error === "kick_reason.host_left") {
alert(translateText("kick_reason.host_left"));
document.dispatchEvent(
new CustomEvent("leave-lobby", {
detail: { lobby: lobbyConfig.gameID, cause: "host-left" },
bubbles: true,
composed: true,
}),
);
} else {
showErrorModal(
message.error,
@@ -193,8 +207,12 @@ export function joinLobby(
return false;
}
console.log("leaving game");
currentGameRunner = null;
transport.leaveGame();
if (currentGameRunner) {
currentGameRunner.stop();
currentGameRunner = null;
} else {
transport.leaveGame();
}
return true;
},
prestart: prestartPromise,
@@ -214,7 +232,7 @@ async function createClientGame(
if (lobbyConfig.gameStartInfo === undefined) {
throw new Error("missing gameStartInfo");
}
const config = await getConfig(
const config = await getGameLogicConfig(
lobbyConfig.gameStartInfo.config,
userSettings,
lobbyConfig.gameRecord !== undefined,
@@ -244,22 +262,34 @@ async function createClientGame(
);
const canvas = createCanvas();
const gameRenderer = createRenderer(canvas, gameView, eventBus);
const soundManager = new SoundManager(eventBus, userSettings);
try {
const gameRenderer = createRenderer(
canvas,
gameView,
eventBus,
lobbyConfig.playerRole,
);
console.log(
`creating private game got difficulty: ${lobbyConfig.gameStartInfo.config.difficulty}`,
);
console.log(
`creating private game got difficulty: ${lobbyConfig.gameStartInfo.config.difficulty}`,
);
return new ClientGameRunner(
lobbyConfig,
clientID,
eventBus,
gameRenderer,
new InputHandler(gameRenderer.uiState, canvas, eventBus),
transport,
worker,
gameView,
);
return new ClientGameRunner(
lobbyConfig,
clientID,
eventBus,
gameRenderer,
new InputHandler(gameView, gameRenderer.uiState, canvas, eventBus),
transport,
worker,
gameView,
soundManager,
);
} catch (err) {
soundManager.dispose();
throw err;
}
}
export class ClientGameRunner {
@@ -285,6 +315,7 @@ export class ClientGameRunner {
private transport: Transport,
private worker: WorkerClient,
private gameView: GameView,
private soundManager: SoundManager,
) {
this.lastMessageTime = Date.now();
}
@@ -331,12 +362,13 @@ export class ClientGameRunner {
Date.now(),
update.winner,
this.lobby.gameStartInfo.lobbyCreatedAt,
this.lobby.gameStartInfo.visibleAt,
);
endGame(record);
}
public start() {
SoundManager.playBackgroundMusic();
this.soundManager.playBackgroundMusic();
console.log("starting client game");
this.isActive = true;
@@ -359,6 +391,14 @@ export class ClientGameRunner {
DoGroundAttackEvent,
this.doGroundAttackUnderCursor.bind(this),
);
this.eventBus.on(
DoRequestAllianceEvent,
this.doRequestAllianceUnderCursor.bind(this),
);
this.eventBus.on(
DoBreakAllianceEvent,
this.doBreakAllianceUnderCursor.bind(this),
);
this.renderer.initialize();
this.input.initialize();
@@ -514,7 +554,7 @@ export class ClientGameRunner {
}
public stop() {
SoundManager.stopBackgroundMusic();
this.soundManager.dispose();
if (!this.isActive) return;
this.isActive = false;
@@ -632,17 +672,52 @@ export class ClientGameRunner {
}
}
if (upgradeUnits.length > 0) {
const bestUpgrade = findClosestBy(upgradeUnits, (u) => u.distance);
if (bestUpgrade) {
this.eventBus.emit(
new SendUpgradeStructureIntentEvent(
bestUpgrade.unitId,
bestUpgrade.unitType,
),
);
if (upgradeUnits.length === 0) {
return;
}
// Upgrade the closest affordable building. But if there's an unaffordable
// building (any type) that's closer to clickedTile than the best candidate,
// do nothing — the player clicked on that unaffordable building intending
// to upgrade it, and we must not spend their gold on a different building.
const bestUpgrade = findClosestBy(upgradeUnits, (u) => u.distance);
if (!bestUpgrade) {
return;
}
// Check if any unaffordable building is closer than bestUpgrade
for (const bu of actions.buildableUnits) {
if (bu.canUpgrade === false && bu.type !== bestUpgrade.unitType) {
const myPlayerID = this.myPlayer!.id();
const closestOfType = this.gameView
.nearbyUnits(
clickedTile,
this.gameView.config().structureMinDist(),
bu.type,
)
.filter(({ unit }) => unit.owner().id() === myPlayerID)
.sort((a, b) => a.distSquared - b.distSquared)[0];
if (closestOfType) {
const dist = this.gameView.manhattanDist(
clickedTile,
closestOfType.unit.tile(),
);
if (dist <= bestUpgrade.distance) {
// An unaffordable building of type bu.type is at least as close
// as bestUpgrade — player clicked on it, not on bestUpgrade.
return;
}
}
}
}
this.eventBus.emit(
new SendUpgradeStructureIntentEvent(
bestUpgrade.unitId,
bestUpgrade.unitType,
),
);
});
}
@@ -697,6 +772,58 @@ export class ClientGameRunner {
});
}
private doRequestAllianceUnderCursor(): void {
const tile = this.getTileUnderCursor();
if (tile === null) return;
if (this.myPlayer === null) {
if (!this.clientID) return;
const myPlayer = this.gameView.playerByClientID(this.clientID);
if (myPlayer === null) return;
this.myPlayer = myPlayer;
}
const myPlayer = this.myPlayer;
const tileOwner = this.gameView.owner(tile);
if (!tileOwner.isPlayer()) return;
const recipient = tileOwner as PlayerView;
myPlayer.actions(tile).then((actions) => {
if (actions.interaction?.canSendAllianceRequest) {
this.eventBus.emit(
new SendAllianceRequestIntentEvent(myPlayer, recipient),
);
}
});
}
private doBreakAllianceUnderCursor(): void {
const tile = this.getTileUnderCursor();
if (tile === null) return;
if (this.myPlayer === null) {
if (!this.clientID) return;
const myPlayer = this.gameView.playerByClientID(this.clientID);
if (myPlayer === null) return;
this.myPlayer = myPlayer;
}
const myPlayer = this.myPlayer;
const tileOwner = this.gameView.owner(tile);
if (!tileOwner.isPlayer()) return;
const recipient = tileOwner as PlayerView;
myPlayer.actions(tile).then((actions) => {
if (actions.interaction?.canBreakAlliance) {
this.eventBus.emit(
new SendBreakAllianceIntentEvent(myPlayer, recipient),
);
}
});
}
private getTileUnderCursor(): TileRef | null {
if (!this.isActive || !this.lastMousePosition) {
return null;
+305 -53
View File
@@ -1,9 +1,13 @@
import { assetUrl } from "src/core/AssetUrls";
import { UserMeResponse } from "../core/ApiSchemas";
import {
ColorPalette,
Cosmetics,
CosmeticsSchema,
Flag,
Pack,
Pattern,
Product,
} from "../core/CosmeticSchemas";
import {
PlayerCosmeticRefs,
@@ -11,35 +15,79 @@ import {
PlayerPattern,
} from "../core/Schemas";
import { UserSettings } from "../core/game/UserSettings";
import { createCheckoutSession, getApiBase, getUserMe } from "./Api";
import {
createCheckoutSession,
getApiBase,
getUserMe,
invalidateUserMe,
purchaseWithCurrency,
} from "./Api";
import { translateText } from "./Utils";
export const TEMP_FLARE_OFFSET = 1 * 60 * 1000; // 1 minute
export async function handlePurchase(
pattern: Pattern,
colorPalette: ColorPalette | null,
) {
if (pattern.product === null) {
alert("This pattern is not available for purchase.");
return;
}
const url = await createCheckoutSession(
pattern.product.priceId,
colorPalette?.name ?? null,
);
if (url === false) {
alert("Failed to create checkout session.");
return;
}
// Redirect to Stripe checkout
window.location.href = url;
}
let __cosmetics: Promise<Cosmetics | null> | null = null;
let __cosmeticsHash: string | null = null;
export type PaymentMethod = "dollar" | "hard" | "soft";
export async function purchaseCosmetic(
resolved: ResolvedCosmetic,
method: PaymentMethod,
): Promise<void> {
if (!resolved.cosmetic) return;
const c = resolved.cosmetic;
const colorPaletteName = resolved.colorPalette?.name;
if (method === "dollar") {
if (!c.product) {
alert(translateText("store.checkout_failed"));
return;
}
const url = await createCheckoutSession(
c.product.priceId,
colorPaletteName,
);
if (url === false) {
alert(translateText("store.checkout_failed"));
return;
}
window.location.href = url;
return;
}
// Currency purchase (hard or soft)
const price = method === "hard" ? (c.priceHard ?? 0) : (c.priceSoft ?? 0);
const userMe = await getUserMe();
if (userMe === false) {
alert(translateText("store.login_required"));
return;
}
const balance =
method === "hard"
? (userMe.player.currency?.hard ?? 0)
: (userMe.player.currency?.soft ?? 0);
if (balance < price) {
alert(translateText("store.not_enough_currency"));
return;
}
const cosmeticType = resolved.type as "pattern" | "skin" | "flag";
const success = await purchaseWithCurrency(
cosmeticType,
c.name,
method,
colorPaletteName,
);
if (!success) {
alert(translateText("store.purchase_failed"));
return;
}
alert(translateText("store.purchase_success", { name: c.name }));
invalidateUserMe();
window.location.reload();
}
function simpleHash(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
@@ -80,54 +128,225 @@ export async function fetchCosmetics(): Promise<Cosmetics | null> {
return __cosmetics;
}
export async function resolveFlagUrl(
flagRef: string,
): Promise<string | undefined> {
if (flagRef.startsWith("flag:")) {
const key = flagRef.slice("flag:".length);
const cosmetics = await fetchCosmetics();
const flagData = cosmetics?.flags?.[key];
return flagData?.url;
}
if (flagRef.startsWith("country:")) {
const code = flagRef.slice("country:".length);
return assetUrl(`flags/${code}.svg`);
}
return undefined;
}
export async function getCosmeticsHash(): Promise<string | null> {
await fetchCosmetics();
return __cosmeticsHash;
}
export function cosmeticRelationship(
opts: {
wildcardFlare: string;
requiredFlare: string;
product: Product | null;
priceSoft?: number;
priceHard?: number;
affiliateCode: string | null;
itemAffiliateCode: string | null;
},
userMeResponse: UserMeResponse | false,
): "owned" | "purchasable" | "blocked" {
const flares =
userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
if (flares.includes(opts.wildcardFlare)) {
return "owned";
}
if (flares.includes(opts.requiredFlare)) {
return "owned";
}
if (opts.affiliateCode !== opts.itemAffiliateCode) {
return "blocked";
}
// Purchasable if any purchase method is available
if (opts.priceSoft !== undefined || opts.priceHard !== undefined) {
return "purchasable";
}
if (opts.product === null) {
return "blocked";
}
return "purchasable";
}
export function patternRelationship(
pattern: Pattern,
colorPalette: { name: string; isArchived?: boolean } | null,
userMeResponse: UserMeResponse | false,
affiliateCode: string | null,
): "owned" | "purchasable" | "blocked" {
const flares =
userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
if (flares.includes("pattern:*")) {
return "owned";
}
if (colorPalette === null) {
// For backwards compatibility only show non-colored patterns if they are owned.
if (flares.includes(`pattern:${pattern.name}`)) {
const flares =
userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
if (
flares.includes("pattern:*") ||
flares.includes(`pattern:${pattern.name}`)
) {
return "owned";
}
return "blocked";
}
const requiredFlare = `pattern:${pattern.name}:${colorPalette.name}`;
if (flares.includes(requiredFlare)) {
return "owned";
}
if (pattern.product === null) {
// We don't own it and it's not for sale, so don't show it.
if (colorPalette.isArchived) {
// Check ownership first — if owned, show it even if archived.
const flares =
userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
if (
flares.includes("pattern:*") ||
flares.includes(`pattern:${pattern.name}:${colorPalette.name}`)
) {
return "owned";
}
return "blocked";
}
if (colorPalette?.isArchived) {
// We don't own the color palette, and it's archived, so don't show it.
return "blocked";
return cosmeticRelationship(
{
wildcardFlare: "pattern:*",
requiredFlare: `pattern:${pattern.name}:${colorPalette.name}`,
product: pattern.product,
priceSoft: pattern.priceSoft,
priceHard: pattern.priceHard,
affiliateCode,
itemAffiliateCode: pattern.affiliateCode,
},
userMeResponse,
);
}
export function flagRelationship(
flag: Flag,
userMeResponse: UserMeResponse | false,
affiliateCode: string | null,
): "owned" | "purchasable" | "blocked" {
return cosmeticRelationship(
{
wildcardFlare: "flag:*",
requiredFlare: `flag:${flag.name}`,
product: flag.product,
priceSoft: flag.priceSoft,
priceHard: flag.priceHard,
affiliateCode,
itemAffiliateCode: flag.affiliateCode,
},
userMeResponse,
);
}
export type ResolvedCosmetic = {
type: "pattern" | "flag" | "pack";
cosmetic: Pattern | Flag | Pack | null;
colorPalette: ColorPalette | null;
relationship: "owned" | "purchasable" | "blocked";
/** Unique key for selection/identity, e.g. "pattern:hearts:red" or "flag:cool_flag" */
key: string;
};
/**
* Resolves all cosmetics into a flat display-ready list with relationship
* status and resolved color palettes. Callers can filter by relationship.
*/
export function resolveCosmetics(
cosmetics: Cosmetics | null,
userMeResponse: UserMeResponse | false,
affiliateCode: string | null,
): ResolvedCosmetic[] {
if (!cosmetics) return [];
const result: ResolvedCosmetic[] = [];
// Default pattern (always owned)
result.push({
type: "pattern",
cosmetic: null,
colorPalette: null,
relationship: "owned",
key: "pattern:default",
});
// Patterns × color palettes
for (const [patternKey, pattern] of Object.entries(cosmetics.patterns)) {
const colorPalettes = [...(pattern.colorPalettes ?? []), null];
for (const cp of colorPalettes) {
const rel = patternRelationship(
pattern,
cp,
userMeResponse,
affiliateCode,
);
const resolvedPalette = cp
? (cosmetics.colorPalettes?.[cp.name] ?? null)
: null;
const key = cp
? `pattern:${patternKey}:${cp.name}`
: `pattern:${patternKey}`;
result.push({
type: "pattern",
cosmetic: pattern,
colorPalette: resolvedPalette,
relationship: rel,
key,
});
}
}
if (affiliateCode !== pattern.affiliateCode) {
// Pattern is for sale, but it's not the right store to show it on.
return "blocked";
// Flags
for (const [flagKey, flag] of Object.entries(cosmetics.flags)) {
const rel = flagRelationship(flag, userMeResponse, affiliateCode);
result.push({
type: "flag",
cosmetic: flag,
colorPalette: null,
relationship: rel,
key: `flag:${flagKey}`,
});
}
// Patterns is for sale, and it's the right store to show it on.
return "purchasable";
// Packs
for (const [packKey, pack] of Object.entries(cosmetics.currencyPacks ?? {})) {
const rel = pack.product ? "purchasable" : "blocked";
result.push({
type: "pack",
cosmetic: pack,
colorPalette: null,
relationship: rel,
key: `pack:${packKey}`,
});
}
return result;
}
export function resolvedToPlayerPattern(
resolved: ResolvedCosmetic,
): PlayerPattern | null {
if (resolved.type !== "pattern") return null;
const c = resolved.cosmetic;
if (c === null) return null;
return {
name: c.name,
patternData: (c as Pattern).pattern,
colorPalette: resolved.colorPalette ?? undefined,
};
}
export async function getPlayerCosmeticsRefs(): Promise<PlayerCosmeticRefs> {
@@ -154,9 +373,34 @@ export async function getPlayerCosmeticsRefs(): Promise<PlayerCosmeticRefs> {
}
}
let flag = userSettings.getFlag();
if (flag?.startsWith("flag:")) {
const key = flag.slice("flag:".length);
const flagData = cosmetics?.flags?.[key];
if (!flagData) {
// Only clear if cosmetics loaded successfully but the key is missing
if (cosmetics) {
flag = null;
}
} else {
const userMe = await getUserMe();
if (!userMe) {
flag = null;
} else {
const flares = userMe.player.flares ?? [];
const hasWildcard = flares.includes("flag:*");
if (!hasWildcard && !flares.includes(`flag:${flagData.name}`)) {
flag = null;
}
}
}
}
if (flag === null) {
userSettings.clearFlag();
}
return {
flag: userSettings.getFlag(),
color: userSettings.getSelectedColor() ?? undefined,
flag: flag ?? undefined,
patternName: pattern?.name ?? undefined,
patternColorPaletteName: pattern?.colorPalette?.name ?? undefined,
};
@@ -169,11 +413,7 @@ export async function getPlayerCosmetics(): Promise<PlayerCosmetics> {
const result: PlayerCosmetics = {};
if (refs.flag) {
result.flag = refs.flag;
}
if (refs.color) {
result.color = { color: refs.color };
result.flag = await resolveFlagUrl(refs.flag);
}
if (refs.patternName && cosmetics) {
@@ -202,3 +442,15 @@ export async function getPlayerCosmetics(): Promise<PlayerCosmetics> {
return result;
}
export function translateCosmetic(prefix: string, name: string): string {
const translation = translateText(`${prefix}.${name}`);
if (translation.startsWith(prefix)) {
return name
.split("_")
.filter((word) => word.length > 0)
.map((word) => word[0].toUpperCase() + word.substring(1))
.join(" ");
}
return translation;
}
+74 -2
View File
@@ -25,6 +25,16 @@ declare global {
},
) => void;
};
banner: {
requestBanner: (options: {
id: string;
width: number;
height: number;
}) => Promise<void>;
requestResponsiveBanner: (containerId: string) => Promise<void>;
clearBanner: (containerId: string) => void;
clearAllBanners: () => void;
};
game: {
gameplayStart: () => Promise<void>;
gameplayStop: () => Promise<void>;
@@ -76,11 +86,9 @@ 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
const isCrazyGames = document.referrer.includes("crazygames");
console.log("[CrazyGames], contains referrer: ", isCrazyGames);
if (isCrazyGames) {
return true;
}
@@ -323,6 +331,70 @@ export class CrazyGamesSDK {
}
}
private bottomLeftContainerId = "cg-bottom-left-ad";
private bottomLeftAdVisible = false;
createBottomLeftAd(): void {
console.log(
`[CrazyGames] createBottomLeftAd called, isReady=${this.isReady()}`,
);
if (!this.isReady()) {
console.log("[CrazyGames] SDK not ready, skipping bottom-left ad");
return;
}
if (this.bottomLeftAdVisible) {
console.log("[CrazyGames] Bottom-left ad already visible");
return;
}
// Remove existing container if any
document.getElementById(this.bottomLeftContainerId)?.remove();
const container = document.createElement("div");
container.id = this.bottomLeftContainerId;
container.style.cssText = `
position: fixed;
bottom: 0;
left: 0;
width: 300px;
height: 250px;
z-index: 9999;
pointer-events: auto;
`;
document.body.appendChild(container);
console.log("[CrazyGames] Created bottom-left ad container");
(async () => {
try {
await window.CrazyGames!.SDK.banner.requestBanner({
id: this.bottomLeftContainerId,
width: 300,
height: 250,
});
console.log("[CrazyGames] Bottom-left banner loaded");
} catch (e) {
console.log("[CrazyGames] Bottom-left banner error:", e);
}
})();
this.bottomLeftAdVisible = true;
}
clearBottomLeftAd(): void {
if (!this.bottomLeftAdVisible) return;
try {
window.CrazyGames!.SDK.banner.clearBanner(this.bottomLeftContainerId);
} catch (e) {
console.error("[CrazyGames] Error clearing bottom-left banner:", e);
}
document.getElementById(this.bottomLeftContainerId)?.remove();
this.bottomLeftAdVisible = false;
console.log("[CrazyGames] Bottom-left ad cleared");
}
requestMidgameAd(): Promise<void> {
return new Promise((resolve) => {
if (!this.isReady()) {
+35 -56
View File
@@ -1,12 +1,14 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { assetUrl } from "../core/AssetUrls";
import { renderPlayerFlag } from "../core/CustomFlag";
import { FlagSchema } from "../core/Schemas";
import { FlagName } from "../core/Schemas";
import {
FLAG_KEY,
USER_SETTINGS_CHANGED_EVENT,
UserSettings,
} from "../core/game/UserSettings";
import { resolveFlagUrl } from "./Cosmetics";
import { translateText } from "./Utils";
const flagKey: string = "flag";
@customElement("flag-input")
export class FlagInput extends LitElement {
@state() public flag: string = "";
@@ -15,36 +17,17 @@ export class FlagInput extends LitElement {
public showSelectLabel: boolean = false;
private isDefaultFlagValue(flag: string): boolean {
return !flag || flag === "xx";
return !flag || flag === "xx" || flag === "country:xx";
}
public getCurrentFlag(): string {
return this.flag;
}
private getStoredFlag(): string {
const storedFlag = localStorage.getItem(flagKey);
if (storedFlag) {
return storedFlag;
private updateFlag = (e: CustomEvent) => {
const val = e.detail ?? "";
const parsed = FlagName.safeParse(val);
if (!parsed.success) {
console.warn(`error parsing flag ${val}, ${parsed.error}`);
}
return "";
}
private dispatchFlagEvent() {
this.dispatchEvent(
new CustomEvent("flag-change", {
detail: { flag: this.flag },
bubbles: true,
composed: true,
}),
);
}
private updateFlag = (ev: Event) => {
const e = ev as CustomEvent<{ flag: string }>;
if (!FlagSchema.safeParse(e.detail.flag).success) return;
if (this.flag !== e.detail.flag) {
this.flag = e.detail.flag;
if (this.flag !== val) {
this.flag = val;
}
};
@@ -61,14 +44,19 @@ export class FlagInput extends LitElement {
connectedCallback() {
super.connectedCallback();
this.flag = this.getStoredFlag();
this.dispatchFlagEvent();
window.addEventListener("flag-change", this.updateFlag as EventListener);
this.flag = new UserSettings().getFlag() ?? "";
window.addEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${FLAG_KEY}`,
this.updateFlag as EventListener,
);
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("flag-change", this.updateFlag as EventListener);
window.removeEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${FLAG_KEY}`,
this.updateFlag as EventListener,
);
}
createRenderRoot() {
@@ -95,7 +83,7 @@ export class FlagInput extends LitElement {
></span>
${showSelect
? html`<span
class="text-[10px] font-medium tracking-wider text-white uppercase leading-none break-words w-full text-center px-1"
class="text-[7px] lg:text-[10px] font-black tracking-wider text-white uppercase leading-tight lg:leading-none w-full text-center px-0.5 lg:px-1"
>
${translateText("flag_input.title")}
</span>`
@@ -104,35 +92,26 @@ export class FlagInput extends LitElement {
`;
}
updated() {
async updated() {
const preview = this.renderRoot.querySelector(
"#flag-preview",
) as HTMLElement;
if (!preview) return;
if (this.showSelectLabel && this.isDefaultFlagValue(this.flag)) {
if (this.isDefaultFlagValue(this.flag)) {
preview.innerHTML = "";
return;
}
preview.innerHTML = "";
if (this.flag?.startsWith("!")) {
renderPlayerFlag(this.flag, preview);
} else {
const img = document.createElement("img");
const fallbackFlagUrl = assetUrl("flags/xx.svg");
img.src = this.flag
? assetUrl(`flags/${this.flag}.svg`)
: fallbackFlagUrl;
img.className = "w-full h-full object-cover pointer-events-none";
img.draggable = false;
img.onerror = () => {
if (!img.src.endsWith(fallbackFlagUrl)) {
img.src = fallbackFlagUrl;
}
};
preview.appendChild(img);
}
const url = await resolveFlagUrl(this.flag);
if (!url) return;
const img = document.createElement("img");
img.src = url;
img.className = "w-full h-full object-cover pointer-events-none";
img.draggable = false;
preview.appendChild(img);
}
}
+131 -50
View File
@@ -1,22 +1,127 @@
import { html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { customElement, state } from "lit/decorators.js";
import Countries from "resources/countries.json" with { type: "json" };
import { assetUrl } from "../core/AssetUrls";
import { UserMeResponse } from "src/core/ApiSchemas";
import { assetUrl } from "src/core/AssetUrls";
import { Cosmetics, Flag } from "src/core/CosmeticSchemas";
import { UserSettings } from "src/core/game/UserSettings";
import { getUserMe } from "./Api";
import {
fetchCosmetics,
flagRelationship,
ResolvedCosmetic,
} from "./Cosmetics";
import { translateText } from "./Utils";
import { BaseModal } from "./components/BaseModal";
import "./components/CosmeticButton";
import "./components/NotLoggedInWarning";
import { modalHeader } from "./components/ui/ModalHeader";
function countryFlag(name: string, code: string): Flag {
return {
name,
url: assetUrl(`/flags/${code}.svg`),
product: null,
rarity: "common",
affiliateCode: null,
};
}
@customElement("flag-input-modal")
export class FlagInputModal extends BaseModal {
@query("#flag-input-modal") private modalRef!: HTMLElement;
@state() private search = "";
@state() private cosmetics: Cosmetics | null = null;
@state() private userMe: UserMeResponse | false = false;
public returnTo = "";
updated(changedProperties: Map<string | number | symbol, unknown>) {
super.updated(changedProperties);
}
private renderFlags() {
const userSettings = new UserSettings();
const selectedFlag = userSettings.getFlag() ?? "";
const cosmeticFlags = Object.entries(this.cosmetics?.flags ?? {})
.filter(([, flag]) => {
if (!this.includedInSearch({ name: flag.name, code: flag.name }))
return false;
return flagRelationship(flag, this.userMe, null) === "owned";
})
.map(([key, flag]) => {
const r: ResolvedCosmetic = {
type: "flag",
cosmetic: flag,
colorPalette: null,
relationship: "owned",
key: `flag:${key}`,
};
return html`
<cosmetic-button
.resolved=${r}
.selected=${selectedFlag === `flag:${key}`}
.onSelect=${() => {
this.setFlag(`flag:${key}`);
this.close();
}}
></cosmetic-button>
`;
});
const noFlagResolved: ResolvedCosmetic = {
type: "flag",
cosmetic: countryFlag("None", "xx"),
colorPalette: null,
relationship: "owned",
key: "country:xx",
};
const noFlag = this.search
? null
: html`
<cosmetic-button
.resolved=${noFlagResolved}
.selected=${selectedFlag === "" || selectedFlag === "country:xx"}
.onSelect=${() => {
this.setFlag("country:xx");
this.close();
}}
></cosmetic-button>
`;
const countryFlags = Countries.filter(
(country) =>
country.code !== "xx" &&
!country.restricted &&
this.includedInSearch(country),
).map((country) => {
const r: ResolvedCosmetic = {
type: "flag",
cosmetic: countryFlag(country.name, country.code),
colorPalette: null,
relationship: "owned",
key: `country:${country.code}`,
};
return html`
<cosmetic-button
.resolved=${r}
.selected=${selectedFlag === `country:${country.code}`}
.onSelect=${() => {
this.setFlag(`country:${country.code}`);
this.close();
}}
></cosmetic-button>
`;
});
return html`
<div
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
>
${noFlag} ${cosmeticFlags} ${countryFlags}
</div>
`;
}
render() {
const content = html`
<div class="${this.modalContainerClass}">
@@ -27,6 +132,7 @@ export class FlagInputModal extends BaseModal {
title: translateText("flag_input.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
rightContent: html`<not-logged-in-warning></not-logged-in-warning>`,
})}
<div class="md:flex items-center gap-2 justify-center mt-4">
@@ -36,50 +142,28 @@ export class FlagInputModal extends BaseModal {
focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 text-white placeholder-white/30 transition-all"
type="text"
placeholder=${translateText("flag_input.search_flag")}
.value=${this.search}
@change=${this.handleSearch}
@keyup=${this.handleSearch}
/>
</div>
</div>
<div class="flex justify-center py-3 shrink-0">
<button
class="no-crazygames px-4 py-2 text-sm font-bold uppercase tracking-wider rounded-lg bg-blue-600 hover:bg-blue-700 text-white cursor-pointer transition-colors"
@click=${() => {
this.close();
window.showPage?.("page-item-store");
}}
>
${translateText("main.store")}
</button>
</div>
<div
class="flex-1 overflow-y-auto px-6 pb-6 scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent mr-1"
class="flex-1 overflow-y-auto px-3 pb-3 scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent mr-1"
>
<div class="pt-2 flex flex-wrap justify-center gap-4 min-h-min">
${Countries.filter(
(country) =>
!country.restricted && this.includedInSearch(country),
).map(
(country) => html`
<button
@click=${() => {
this.setFlag(country.code);
this.close();
}}
class="group relative flex flex-col items-center gap-2 p-3 rounded-xl border border-white/5 bg-white/5 hover:bg-white/10 hover:border-white/20 transition-all cursor-pointer
w-[100px] sm:w-[120px]"
>
<img
class="w-full h-auto rounded group-hover:scale-105 transition-transform duration-200 pointer-events-none"
draggable="false"
src=${assetUrl(`flags/${country.code}.svg`)}
loading="lazy"
@error=${(e: Event) => {
const img = e.currentTarget as HTMLImageElement;
const fallback = assetUrl("flags/xx.svg");
if (img.src && !img.src.endsWith(fallback)) {
img.src = fallback;
}
}}
/>
<span
class="text-xs font-bold text-gray-300 group-hover:text-white text-center leading-tight w-full whitespace-normal break-words"
>${country.name}</span
>
</button>
`,
)}
</div>
${this.renderFlags()}
</div>
</div>
`;
@@ -113,21 +197,18 @@ export class FlagInputModal extends BaseModal {
}
private setFlag(flag: string) {
localStorage.setItem("flag", flag);
this.dispatchEvent(
new CustomEvent("flag-change", {
detail: { flag },
bubbles: true,
composed: true,
}),
);
new UserSettings().setFlag(flag);
}
protected onOpen(): void {
// No custom logic needed
protected async onOpen(): Promise<void> {
[this.cosmetics, this.userMe] = await Promise.all([
fetchCosmetics(),
getUserMe().then((r) => r || (false as const)),
]);
}
protected onClose(): void {
this.search = "";
if (this.returnTo) {
const returnEl = document.querySelector(this.returnTo) as any;
if (returnEl?.open) {
+10 -6
View File
@@ -1,6 +1,6 @@
import { html, LitElement, nothing, type TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import { getServerConfigFromClient } from "src/core/configuration/ConfigLoader";
import { getRuntimeClientServerConfig } from "src/core/configuration/ConfigLoader";
import {
Duos,
GameMapType,
@@ -10,6 +10,7 @@ import {
Trios,
} from "../core/game/Game";
import { PublicGameInfo, PublicGames } from "../core/Schemas";
import "./components/IOSAddToHomeScreenBanner";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import { HostLobbyModal } from "./HostLobbyModal";
import { JoinLobbyModal } from "./JoinLobbyModal";
@@ -58,7 +59,7 @@ export class GameModeSelector extends LitElement {
connectedCallback() {
super.connectedCallback();
this.lobbySocket.start();
getServerConfigFromClient().then((config) => {
getRuntimeClientServerConfig().then((config) => {
this.defaultLobbyTime = config.gameCreationRate() / 1000;
});
}
@@ -119,7 +120,7 @@ export class GameModeSelector extends LitElement {
${this.renderSmallActionCard(
translateText("main.solo"),
this.openSinglePlayerModal,
"bg-sky-600 hover:bg-sky-500 active:bg-sky-700",
"bg-[#0073b7] hover:bg-sky-500 active:bg-sky-700",
)}
</div>
<!-- Create/ranked/join: mobile only, below solo -->
@@ -142,6 +143,9 @@ export class GameModeSelector extends LitElement {
"bg-slate-600 hover:bg-slate-500 active:bg-slate-700",
)}
</div>
<!-- iOS Add to Home Screen banner -->
<ios-add-to-home-screen-banner></ios-add-to-home-screen-banner>
<!-- Game cards grid -->
<div
class="grid grid-cols-1 sm:grid-cols-[2fr_1fr] gap-4 sm:h-[min(24rem,40vh)]"
@@ -188,7 +192,7 @@ export class GameModeSelector extends LitElement {
${this.renderSmallActionCard(
translateText("main.solo"),
this.openSinglePlayerModal,
"bg-sky-600 hover:bg-sky-500 active:bg-sky-700",
"bg-[#0073b7] hover:bg-sky-500 active:bg-sky-700",
)}
</div>
<!-- Bottom row: create + ranked + join (desktop only) -->
@@ -321,7 +325,7 @@ export class GameModeSelector extends LitElement {
${modifierLabels.map(
(label) =>
html`<span
class="px-2 py-1 rounded text-xs font-bold uppercase tracking-widest bg-sky-600 text-white shadow-[0_0_6px_rgba(14,165,233,0.35)]"
class="px-2 py-1 rounded text-xs font-bold uppercase tracking-widest bg-[#0073b7] text-white shadow-[0_0_6px_rgba(14,165,233,0.35)]"
>${label}</span
>`,
)}
@@ -331,7 +335,7 @@ export class GameModeSelector extends LitElement {
<span
class="text-xs font-bold tracking-widest ${timeDisplayUppercase
? "uppercase"
: "normal-case"} bg-sky-600 text-white px-2 py-1 rounded"
: "normal-case"} bg-[#0073b7] text-white px-2 py-1 rounded"
>${timeDisplay}</span
>
</div>
-162
View File
@@ -1,162 +0,0 @@
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { FOOTER_AD_MIN_HEIGHT } from "./components/HomeFooterAd";
@customElement("gutter-ads")
export class GutterAds extends LitElement {
@state()
private isVisible: boolean = false;
@state()
private adLoaded: boolean = false;
@state()
private hasFooterAd: boolean = false;
private onResize = () => {
const isDesktop = window.innerWidth >= 640;
this.hasFooterAd = isDesktop && window.innerHeight >= FOOTER_AD_MIN_HEIGHT;
};
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";
// Override createRenderRoot to disable shadow DOM
createRenderRoot() {
return this;
}
static styles = css``;
connectedCallback() {
super.connectedCallback();
this.onResize();
window.addEventListener("resize", this.onResize);
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("GutterAdModal DOM is ready");
}
public show(): void {
this.isVisible = true;
this.requestUpdate();
// Wait for the update to complete, then load ads
this.updateComplete.then(() => {
this.loadAds();
});
}
public close(): void {
try {
window.ramp.destroyUnits(this.leftAdType);
window.ramp.destroyUnits(this.rightAdType);
console.log("successfully destroyed gutter ads");
} catch (e) {
console.error("error destroying gutter ads", e);
}
}
private loadAds(): void {
console.log("loading ramp ads");
// Ensure the container elements exist before loading ads
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.ramp) {
console.warn("Playwire RAMP not available");
return;
}
if (this.adLoaded) {
console.log("Ads already loaded, skipping");
return;
}
try {
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 Playwire ads:", error);
}
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("resize", this.onResize);
}
render() {
if (!this.isVisible) {
return html``;
}
return html`
<!-- Left Gutter Ad -->
<div
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-40 pointer-events-auto items-center justify-center xl:[--half-content:10.5cm] 2xl:[--half-content:12.5cm]"
style="left: calc(50% - var(--half-content) - 208px); top: calc(50% + 10px${this
.hasFooterAd
? " - 1.2cm"
: ""});"
>
<div
id="${this.leftContainerId}"
class="w-full h-full flex items-center justify-center p-2"
></div>
</div>
<!-- Right Gutter Ad -->
<div
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-40 pointer-events-auto items-center justify-center xl:[--half-content:10.5cm] 2xl:[--half-content:12.5cm]"
style="left: calc(50% + var(--half-content) + 48px); top: calc(50% + 10px${this
.hasFooterAd
? " - 1.2cm"
: ""});"
>
<div
id="${this.rightContainerId}"
class="w-full h-full flex items-center justify-center p-2"
></div>
</div>
`;
}
}
+24 -50
View File
@@ -2,6 +2,7 @@ import { html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { translateText, TUTORIAL_VIDEO_URL } from "../client/Utils";
import { assetUrl } from "../core/AssetUrls";
import { UserSettings } from "../core/game/UserSettings";
import { BaseModal } from "./components/BaseModal";
import "./components/Difficulties";
import { modalHeader } from "./components/ui/ModalHeader";
@@ -13,57 +14,8 @@ 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 (
typeof v === "object" &&
v !== null &&
"value" in v &&
typeof (v as any).value === "string"
);
}
private getKeybinds(): Record<string, string> {
let saved: Record<string, string> = {};
try {
const parsed = JSON.parse(
localStorage.getItem("settings.keybinds") ?? "{}",
);
saved = Object.fromEntries(
Object.entries(parsed)
.map(([k, v]) => {
if (this.isKeybindObject(v)) return [k, v.value];
if (typeof v === "string") return [k, v];
return [k, undefined];
})
.filter(([, v]) => typeof v === "string" && v !== "Null"),
) as Record<string, string>;
} catch (e) {
console.warn("Invalid keybinds JSON:", e);
}
const isMac = Platform.isMac;
return {
toggleView: "Space",
coordinateGrid: "KeyM",
centerCamera: "KeyC",
moveUp: "KeyW",
moveDown: "KeyS",
moveLeft: "KeyA",
moveRight: "KeyD",
zoomOut: "KeyQ",
zoomIn: "KeyE",
attackRatioDown: "KeyT",
attackRatioUp: "KeyY",
swapDirection: "KeyU",
shiftKey: "ShiftLeft",
modifierKey: isMac ? "MetaLeft" : "ControlLeft",
altKey: "AltLeft",
resetGfx: "KeyR",
pauseGame: "KeyP",
gameSpeedUp: "Period",
gameSpeedDown: "Comma",
...saved,
};
return new UserSettings().keybinds(Platform.isMac);
}
private getKeyLabel(code: string): string {
@@ -482,6 +434,28 @@ export class HelpModal extends BaseModal {
${translateText("help_modal.action_auto_upgrade")}
</td>
</tr>
<tr class="hover:bg-white/5 transition-colors">
<td class="py-3 pl-4 border-b border-white/5">
<div class="inline-flex items-center gap-2">
${this.renderKey(keybinds.shiftKey)}
<span class="text-white/40 font-bold">+</span>
<span class="text-white/50 text-xs"
>${translateText("help_modal.drag")}</span
>
</div>
</td>
<td class="py-3 border-b border-white/5 text-white/70">
${translateText("help_modal.action_warship_multiselect")}
</td>
</tr>
<tr class="hover:bg-white/5 transition-colors">
<td class="py-3 pl-4 border-b border-white/5">
${this.renderKey(keybinds.selectAllWarships)}
</td>
<td class="py-3 border-b border-white/5 text-white/70">
${translateText("help_modal.action_warship_selectall")}
</td>
</tr>
</tbody>
</table>
</div>
+209
View File
@@ -0,0 +1,209 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
// ─── Gutter Ads ──────────────────────────────────────────────────────────────
@customElement("homepage-promos")
export class HomepagePromos extends LitElement {
@state() private isVisible: boolean = false;
@state() private adLoaded: boolean = false;
private cornerAdLoaded: boolean = false;
private onUserMeResponse = () => {
if (window.adsEnabled) {
console.log("showing homepage ads");
this.show();
this.loadCornerAdVideo();
} else {
console.log("not showing homepage ads");
}
};
private onJoinLobby = () => {
this.loadBottomRail();
};
private onLeaveLobby = () => {
this.destroyBottomRail();
};
private bottomRailActive: 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";
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
document.addEventListener("userMeResponse", this.onUserMeResponse);
document.addEventListener("join-lobby", this.onJoinLobby);
document.addEventListener("leave-lobby", this.onLeaveLobby);
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener("userMeResponse", this.onUserMeResponse);
document.removeEventListener("join-lobby", this.onJoinLobby);
document.removeEventListener("leave-lobby", this.onLeaveLobby);
}
public show(): void {
this.isVisible = true;
this.requestUpdate();
this.updateComplete.then(() => {
this.loadGutterAds();
});
}
public close(): void {
this.isVisible = false;
this.adLoaded = false;
try {
// Only destroy gutter ads; bottom_rail persists into spawn phase.
window.ramp.destroyUnits(this.leftAdType);
window.ramp.destroyUnits(this.rightAdType);
console.log("successfully destroyed gutter ads");
} catch (e) {
console.error("error destroying gutter ads", e);
}
}
public loadBottomRail(): void {
if (!window.adsEnabled) return;
if (this.bottomRailActive) return;
if (!window.ramp) {
console.warn("Playwire RAMP not available for bottom_rail ad");
return;
}
this.bottomRailActive = true;
try {
window.ramp.que.push(() => {
try {
window.ramp.spaAddAds([{ type: "bottom_rail" }]);
console.log("Bottom rail ad loaded");
} catch (e) {
console.error("Failed to add bottom_rail ad:", e);
}
});
} catch (error) {
console.error("Failed to load bottom_rail ad:", error);
}
}
public destroyBottomRail(): void {
if (!this.bottomRailActive) return;
this.bottomRailActive = false;
if (!window.ramp) return;
try {
window.ramp.destroyUnits("pw-oop-bottom_rail");
console.log("Bottom rail ad destroyed");
} catch (e) {
console.error("Error destroying bottom_rail ad:", e);
}
}
private loadGutterAds(): void {
console.log("loading ramp gutter ads");
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.ramp) {
console.warn("Playwire RAMP not available");
return;
}
if (this.adLoaded) {
console.log("Ads already loaded, skipping");
return;
}
try {
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("Gutter ads loaded:", this.leftAdType, this.rightAdType);
} catch (e) {
console.log(e);
}
});
} catch (error) {
console.error("Failed to load gutter ads:", error);
}
}
private loadCornerAdVideo(): void {
if (this.cornerAdLoaded) return;
if (window.innerWidth < 1280) return;
if (!window.ramp) {
console.warn("Playwire RAMP not available for corner_ad_video");
return;
}
try {
window.ramp.que.push(() => {
try {
window.ramp
.addUnits([{ type: "corner_ad_video" }])
.then(() => {
this.cornerAdLoaded = true;
window.ramp.displayUnits();
console.log("corner_ad_video loaded");
})
.catch((e: unknown) => {
console.error("Failed to display corner_ad_video:", e);
});
} catch (e) {
console.error("Failed to add corner_ad_video:", e);
}
});
} catch (error) {
console.error("Failed to load corner_ad_video:", error);
}
}
render() {
if (!this.isVisible) {
return html``;
}
return html`
<!-- Left Gutter Ad -->
<div
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-40 pointer-events-auto items-center justify-center xl:[--half-content:10.5cm] 2xl:[--half-content:12.5cm]"
style="left: calc(50% - var(--half-content) - 208px); top: calc(50% + 10px);"
>
<div
id="${this.leftContainerId}"
class="w-full h-full flex items-center justify-center p-2"
></div>
</div>
<!-- Right Gutter Ad -->
<div
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-40 pointer-events-auto items-center justify-center xl:[--half-content:10.5cm] 2xl:[--half-content:12.5cm]"
style="left: calc(50% + var(--half-content) + 48px); top: calc(50% + 10px);"
>
<div
id="${this.rightContainerId}"
class="w-full h-full flex items-center justify-center p-2"
></div>
</div>
`;
}
}
+190 -13
View File
@@ -1,7 +1,7 @@
import { html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { getRuntimeClientServerConfig } from "../core/configuration/ConfigLoader";
import { EventBus } from "../core/EventBus";
import {
Difficulty,
@@ -72,11 +72,19 @@ export class HostLobbyModal extends BaseModal {
@state() private startingGold: boolean = false;
@state() private startingGoldValue: number | undefined = undefined;
@state() private disableAlliances: boolean = false;
@state() private waterNukes: boolean = false;
@state() private lobbyId = "";
@state() private lobbyUrlSuffix = "";
@state() private clients: ClientInfo[] = [];
@state() private useRandomMap: boolean = false;
@state() private disabledUnits: UnitType[] = [];
@state() private hostCheatsEnabled: boolean = false;
@state() private hostCheatInfiniteGold: boolean = false;
@state() private hostCheatInfiniteTroops: boolean = false;
@state() private hostCheatGoldMultiplier: boolean = false;
@state() private hostCheatGoldMultiplierValue: number | undefined = undefined;
@state() private hostCheatStartingGold: boolean = false;
@state() private hostCheatStartingGoldValue: number | undefined = undefined;
@state() private lobbyCreatorClientID: string = "";
@property({ attribute: false }) eventBus: EventBus | null = null;
@@ -113,7 +121,7 @@ export class HostLobbyModal extends BaseModal {
return link;
}
}
const config = await getServerConfigFromClient();
const config = await getRuntimeClientServerConfig();
return `${window.location.origin}/${config.workerPath(this.lobbyId)}/game/${this.lobbyId}?lobby&s=${encodeURIComponent(this.lobbyUrlSuffix)}`;
}
@@ -212,6 +220,45 @@ export class HostLobbyModal extends BaseModal {
></toggle-input-card>`,
];
const hostCheatInputCards = [
html`<toggle-input-card
.labelKey=${"host_modal.gold_multiplier"}
.checked=${this.hostCheatGoldMultiplier}
.inputId=${"host-cheat-gold-multiplier-value"}
.inputMin=${0.1}
.inputMax=${1000}
.inputStep=${"any"}
.inputValue=${this.hostCheatGoldMultiplierValue}
.inputAriaLabel=${translateText("host_modal.gold_multiplier")}
.inputPlaceholder=${translateText(
"host_modal.gold_multiplier_placeholder",
)}
.defaultInputValue=${2}
.minValidOnEnable=${0.1}
.onToggle=${this.handleHostCheatGoldMultiplierToggle}
.onChange=${this.handleHostCheatGoldMultiplierValueChanges}
.onKeyDown=${this.handleHostCheatGoldMultiplierValueKeyDown}
></toggle-input-card>`,
html`<toggle-input-card
.labelKey=${"host_modal.starting_gold"}
.checked=${this.hostCheatStartingGold}
.inputId=${"host-cheat-starting-gold-value"}
.inputMin=${0.1}
.inputMax=${1000}
.inputStep=${"any"}
.inputValue=${this.hostCheatStartingGoldValue}
.inputAriaLabel=${translateText("host_modal.starting_gold")}
.inputPlaceholder=${translateText(
"host_modal.starting_gold_placeholder",
)}
.defaultInputValue=${5}
.minValidOnEnable=${0.1}
.onToggle=${this.handleHostCheatStartingGoldToggle}
.onChange=${this.handleHostCheatStartingGoldValueChanges}
.onKeyDown=${this.handleHostCheatStartingGoldValueKeyDown}
></toggle-input-card>`,
];
const content = html`
<div class="${this.modalContainerClass}">
<!-- Header -->
@@ -299,9 +346,32 @@ export class HostLobbyModal extends BaseModal {
labelKey: "host_modal.disable_alliances",
checked: this.disableAlliances,
},
{
labelKey: "host_modal.water_nukes",
checked: this.waterNukes,
},
{
labelKey: "host_modal.host_cheats",
checked: this.hostCheatsEnabled,
},
],
inputCards,
},
hostCheats: {
titleKey: "host_modal.host_cheats",
visible: this.hostCheatsEnabled,
toggles: [
{
labelKey: "host_modal.infinite_gold",
checked: this.hostCheatInfiniteGold,
},
{
labelKey: "host_modal.infinite_troops",
checked: this.hostCheatInfiniteTroops,
},
],
inputCards: hostCheatInputCards,
},
unitTypes: {
titleKey: "host_modal.enables_title",
disabledUnits: this.disabledUnits,
@@ -315,6 +385,8 @@ export class HostLobbyModal extends BaseModal {
@bots-changed=${this.handleBotsChange}
@nations-changed=${this.handleNationsChange}
@option-toggle-changed=${this.handleConfigOptionToggleChanged}
@host-cheat-toggle-changed=${this
.handleConfigHostCheatToggleChanged}
@unit-toggle-changed=${this.handleConfigUnitToggleChanged}
></game-config-settings>
@@ -333,7 +405,7 @@ export class HostLobbyModal extends BaseModal {
<!-- Player List / footer -->
<div class="p-6 pt-4 border-t border-white/10 bg-black/20 shrink-0">
<button
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-sky-600 hover:bg-sky-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all shadow-lg shadow-sky-900/20 hover:shadow-sky-900/40 hover:-translate-y-0.5 active:translate-y-0 disabled:transform-none"
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-[#0073b7] hover:bg-sky-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all shadow-lg shadow-sky-900/20 hover:shadow-sky-900/40 hover:-translate-y-0.5 active:translate-y-0 disabled:transform-none"
@click=${this.startGame}
?disabled=${this.clients.length < 2}
>
@@ -463,6 +535,14 @@ export class HostLobbyModal extends BaseModal {
this.startingGold = false;
this.startingGoldValue = undefined;
this.disableAlliances = false;
this.waterNukes = false;
this.hostCheatsEnabled = false;
this.hostCheatInfiniteGold = false;
this.hostCheatInfiniteTroops = false;
this.hostCheatGoldMultiplier = false;
this.hostCheatGoldMultiplierValue = undefined;
this.hostCheatStartingGold = false;
this.hostCheatStartingGoldValue = undefined;
this.leaveLobbyOnClose = true;
}
@@ -543,6 +623,35 @@ export class HostLobbyModal extends BaseModal {
this.disableAlliances = checked;
this.putGameConfig();
break;
case "host_modal.water_nukes":
this.waterNukes = checked;
this.putGameConfig();
break;
case "host_modal.host_cheats":
this.hostCheatsEnabled = checked;
this.putGameConfig();
break;
default:
break;
}
};
private handleConfigHostCheatToggleChanged = (e: Event) => {
const customEvent = e as CustomEvent<{
labelKey: string;
checked: boolean;
}>;
const { labelKey, checked } = customEvent.detail;
switch (labelKey) {
case "host_modal.infinite_gold":
this.hostCheatInfiniteGold = checked;
this.putGameConfig();
break;
case "host_modal.infinite_troops":
this.hostCheatInfiniteTroops = checked;
this.putGameConfig();
break;
default:
break;
}
@@ -674,6 +783,61 @@ export class HostLobbyModal extends BaseModal {
this.putGameConfig();
};
private handleHostCheatGoldMultiplierToggle = (
checked: boolean,
value: number | string | undefined,
) => {
this.hostCheatGoldMultiplier = checked;
this.hostCheatGoldMultiplierValue = toOptionalNumber(value);
this.putGameConfig();
};
private handleHostCheatGoldMultiplierValueKeyDown = (e: KeyboardEvent) => {
preventDisallowedKeys(e, ["+", "-", "e", "E"]);
};
private handleHostCheatGoldMultiplierValueChanges = (e: Event) => {
const input = e.target as HTMLInputElement;
const value = parseBoundedFloatFromInput(input, { min: 0.1, max: 1000 });
if (value === undefined) {
this.hostCheatGoldMultiplierValue = undefined;
input.value = "";
} else {
this.hostCheatGoldMultiplierValue = value;
}
this.putGameConfig();
};
private handleHostCheatStartingGoldToggle = (
checked: boolean,
value: number | string | undefined,
) => {
this.hostCheatStartingGold = checked;
this.hostCheatStartingGoldValue = toOptionalNumber(value);
this.putGameConfig();
};
private handleHostCheatStartingGoldValueKeyDown = (e: KeyboardEvent) => {
preventDisallowedKeys(e, ["-", "+", "e", "E"]);
};
private handleHostCheatStartingGoldValueChanges = (e: Event) => {
const input = e.target as HTMLInputElement;
const value = parseBoundedFloatFromInput(input, {
min: 0.1,
max: 1000,
});
if (value === undefined) {
this.hostCheatStartingGoldValue = undefined;
input.value = "";
} else {
this.hostCheatStartingGoldValue = value;
}
this.putGameConfig();
};
private handleRandomSpawnChange = (val: boolean) => {
this.randomSpawn = val;
this.putGameConfig();
@@ -789,23 +953,36 @@ export class HostLobbyModal extends BaseModal {
disabledUnits: this.disabledUnits,
spawnImmunityDuration: this.spawnImmunity
? spawnImmunityTicks
: undefined,
: null,
playerTeams: this.teamCount,
nations: sliderToNationsConfig(
this.nations,
this.defaultNationCount,
),
maxTimerValue:
this.maxTimer === true ? this.maxTimerValue : undefined,
maxTimerValue: this.maxTimer === true ? this.maxTimerValue : null,
goldMultiplier:
this.goldMultiplier === true
? this.goldMultiplierValue
: undefined,
this.goldMultiplier === true ? this.goldMultiplierValue : null,
startingGold:
this.startingGold === true && this.startingGoldValue !== undefined
? Math.round(this.startingGoldValue * 1_000_000)
: undefined,
disableAlliances: this.disableAlliances || undefined,
: null,
disableAlliances: this.disableAlliances || null,
waterNukes: this.waterNukes ? true : null,
hostCheats: this.hostCheatsEnabled
? {
infiniteGold: this.hostCheatInfiniteGold || undefined,
infiniteTroops: this.hostCheatInfiniteTroops || undefined,
goldMultiplier:
this.hostCheatGoldMultiplier === true
? this.hostCheatGoldMultiplierValue
: null,
startingGold:
this.hostCheatStartingGold === true &&
this.hostCheatStartingGoldValue !== undefined
? Math.round(this.hostCheatStartingGoldValue * 1_000_000)
: null,
}
: undefined,
} satisfies Partial<GameConfig>,
},
bubbles: true,
@@ -823,7 +1000,7 @@ export class HostLobbyModal extends BaseModal {
// If the modal closes as part of starting the game, do not leave the lobby
this.leaveLobbyOnClose = false;
const config = await getServerConfigFromClient();
const config = await getRuntimeClientServerConfig();
const response = await fetch(
`${window.location.origin}/${config.workerPath(this.lobbyId)}/api/start_game/${this.lobbyId}`,
{
@@ -871,7 +1048,7 @@ export class HostLobbyModal extends BaseModal {
}
async function createLobby(gameID: string): Promise<GameInfo> {
const config = await getServerConfigFromClient();
const config = await getRuntimeClientServerConfig();
// Send JWT token for creator identification - server extracts persistentID from it
// persistentID should never be exposed to other clients
const token = await getPlayToken();
+371 -97
View File
@@ -1,6 +1,6 @@
import { EventBus, GameEvent } from "../core/EventBus";
import { PlayerBuildableUnitType, UnitType } from "../core/game/Game";
import { UnitView } from "../core/game/GameView";
import { GameView, UnitView } from "../core/game/GameView";
import { UserSettings } from "../core/game/UserSettings";
import { UIState } from "./graphics/UIState";
import { Platform } from "./Platform";
@@ -27,12 +27,16 @@ export class TouchEvent implements GameEvent {
}
/**
* Event emitted when a unit is selected or deselected
* Event emitted when one or more warships are selected or deselected.
* For single selection: unit is set, units is empty.
* For multi selection: units contains all selected warships, unit is null.
* For deselection: isSelected is false.
*/
export class UnitSelectionEvent implements GameEvent {
constructor(
public readonly unit: UnitView | null,
public readonly isSelected: boolean,
public readonly units: UnitView[] = [],
) {}
}
@@ -98,6 +102,40 @@ export class SwapRocketDirectionEvent implements GameEvent {
constructor(public readonly rocketDirectionUp: boolean) {}
}
/** Emitted while the user is drawing a shift+drag selection rectangle */
export class WarshipSelectionBoxUpdateEvent implements GameEvent {
constructor(
public readonly startX: number,
public readonly startY: number,
public readonly endX: number,
public readonly endY: number,
) {}
}
/** Emitted when the user releases the mouse after drawing a selection rectangle */
export class WarshipSelectionBoxCompleteEvent implements GameEvent {
constructor(
public readonly startX: number,
public readonly startY: number,
public readonly endX: number,
public readonly endY: number,
) {}
}
/** Emitted when the selection box is cancelled (e.g. Escape or no drag) */
export class WarshipSelectionBoxCancelEvent implements GameEvent {}
/** Emitted when the player triggers select-all-warships hotkey */
export class SelectAllWarshipsEvent implements GameEvent {}
/** Emitted when a touch long-press is detected (shows crosshair indicator) */
export class TouchLongPressStartEvent implements GameEvent {
constructor(
public readonly x: number,
public readonly y: number,
) {}
}
export class ShowBuildMenuEvent implements GameEvent {
constructor(
public readonly x: number,
@@ -115,6 +153,10 @@ export class DoBoatAttackEvent implements GameEvent {}
export class DoGroundAttackEvent implements GameEvent {}
export class DoRequestAllianceEvent implements GameEvent {}
export class DoBreakAllianceEvent implements GameEvent {}
export class AttackRatioEvent implements GameEvent {
constructor(public readonly attackRatio: number) {}
}
@@ -166,6 +208,17 @@ export class InputHandler {
private alternateView = false;
// Warship selection box state
private selectionBoxActive: boolean = false;
// True while warships are selected via box (waiting for move target click)
private multiSelectionActive: boolean = false;
// Touch long-press state
private longPressTimer: ReturnType<typeof setTimeout> | null = null;
private longPressActive: boolean = false;
private suppressNextTap: boolean = false;
private readonly LONG_PRESS_MS = 800;
private moveInterval: NodeJS.Timeout | null = null;
private activeKeys = new Set<string>();
private keybinds: Record<string, string> = {};
@@ -173,83 +226,42 @@ export class InputHandler {
private readonly PAN_SPEED = 5;
private readonly ZOOM_SPEED = 10;
private readonly DRAG_THRESHOLD_PX = 10;
private readonly userSettings: UserSettings = new UserSettings();
constructor(
private gameView: GameView,
public uiState: UIState,
private canvas: HTMLCanvasElement,
private eventBus: EventBus,
) {}
initialize() {
let saved: Record<string, string> = {};
try {
const parsed = JSON.parse(
localStorage.getItem("settings.keybinds") ?? "{}",
);
// flatten { key: {key, value} } → { key: value } and accept legacy string values
saved = Object.fromEntries(
Object.entries(parsed)
.map(([k, v]) => {
// Extract value from nested object or plain string
let val: unknown;
if (v && typeof v === "object" && "value" in v) {
val = (v as { value: unknown }).value;
} else {
val = v;
}
this.keybinds = this.userSettings.keybinds(Platform.isMac);
// Map invalid values to undefined (filtered later)
if (typeof val !== "string") {
return [k, undefined];
}
return [k, val];
})
.filter(([, v]) => typeof v === "string"),
) as Record<string, string>;
} catch (e) {
console.warn("Invalid keybinds JSON:", e);
}
// Mac users might have different keybinds
const isMac = Platform.isMac;
this.keybinds = {
toggleView: "Space",
coordinateGrid: "KeyM",
centerCamera: "KeyC",
moveUp: "KeyW",
moveDown: "KeyS",
moveLeft: "KeyA",
moveRight: "KeyD",
zoomOut: "KeyQ",
zoomIn: "KeyE",
attackRatioDown: "KeyT",
attackRatioUp: "KeyY",
boatAttack: "KeyB",
groundAttack: "KeyG",
swapDirection: "KeyU",
modifierKey: isMac ? "MetaLeft" : "ControlLeft",
altKey: "AltLeft",
buildCity: "Digit1",
buildFactory: "Digit2",
buildPort: "Digit3",
buildDefensePost: "Digit4",
buildMissileSilo: "Digit5",
buildSamLauncher: "Digit6",
buildWarship: "Digit7",
buildAtomBomb: "Digit8",
buildHydrogenBomb: "Digit9",
buildMIRV: "Digit0",
pauseGame: "KeyP",
gameSpeedUp: "Period",
gameSpeedDown: "Comma",
...saved,
};
// Listen for warship selection to change cursor
this.eventBus.on(UnitSelectionEvent, (e) => {
if (e.isSelected && (e.units ?? []).length > 0) {
// Multi-selection active
this.multiSelectionActive = true;
this.canvas.style.cursor = "crosshair";
} else if (e.isSelected) {
// Single warship selected — cursor crosshair, but not multi
this.multiSelectionActive = false;
this.canvas.style.cursor = "crosshair";
} else {
// Deselected
this.multiSelectionActive = false;
if (!this.selectionBoxActive) {
this.canvas.style.cursor = "";
}
}
});
this.canvas.addEventListener("pointerdown", (e) => this.onPointerDown(e));
window.addEventListener("pointerup", (e) => this.onPointerUp(e));
window.addEventListener("pointercancel", (e) => this.onPointerUp(e));
this.canvas.addEventListener(
"wheel",
(e) => {
@@ -266,6 +278,31 @@ export class InputHandler {
this.eventBus.emit(new MouseMoveEvent(e.clientX, e.clientY));
}
});
// Clear all tracked keys when the window loses focus so keys that had
// their keyup swallowed by the browser (e.g. cmd+zoom) don't stay stuck.
// Also release the hold-to-view state and any active pointer/drag state
// so the alternate view and drags aren't left latched when focus returns.
window.addEventListener("blur", () => {
this.activeKeys.clear();
if (this.alternateView) {
this.alternateView = false;
this.eventBus.emit(new AlternateViewEvent(false));
}
this.pointerDown = false;
this.pointers.clear();
if (this.longPressTimer !== null) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
this.longPressActive = false;
this.suppressNextTap = false;
if (this.selectionBoxActive || this.multiSelectionActive) {
this.selectionBoxActive = false;
this.multiSelectionActive = false;
this.eventBus.emit(new WarshipSelectionBoxCancelEvent());
}
this.canvas.style.cursor = "";
});
this.pointers.clear();
this.moveInterval = setInterval(() => {
@@ -273,10 +310,7 @@ export class InputHandler {
let deltaY = 0;
// Skip if shift is held down
if (
this.activeKeys.has("ShiftLeft") ||
this.activeKeys.has("ShiftRight")
) {
if (this.activeKeys.has(this.keybinds.shiftKey)) {
return;
}
@@ -310,13 +344,15 @@ export class InputHandler {
if (
this.activeKeys.has(this.keybinds.zoomOut) ||
this.activeKeys.has("Minus")
this.activeKeys.has("Minus") ||
this.activeKeys.has("NumpadSubtract")
) {
this.eventBus.emit(new ZoomEvent(cx, cy, this.ZOOM_SPEED));
}
if (
this.activeKeys.has(this.keybinds.zoomIn) ||
this.activeKeys.has("Equal")
this.activeKeys.has("Equal") ||
this.activeKeys.has("NumpadAdd")
) {
this.eventBus.emit(new ZoomEvent(cx, cy, -this.ZOOM_SPEED));
}
@@ -328,7 +364,7 @@ export class InputHandler {
return;
}
if (e.code === this.keybinds.toggleView) {
if (this.keybindMatchesEvent(e, this.keybinds.toggleView)) {
e.preventDefault();
if (!this.alternateView) {
this.alternateView = true;
@@ -336,7 +372,10 @@ export class InputHandler {
}
}
if (e.code === this.keybinds.coordinateGrid && !e.repeat) {
if (
this.keybindMatchesEvent(e, this.keybinds.coordinateGrid) &&
!e.repeat
) {
e.preventDefault();
this.coordinateGridEnabled = !this.coordinateGridEnabled;
this.eventBus.emit(
@@ -348,6 +387,11 @@ export class InputHandler {
e.preventDefault();
this.eventBus.emit(new CloseViewEvent());
this.setGhostStructure(null);
if (this.selectionBoxActive || this.multiSelectionActive) {
this.selectionBoxActive = false;
this.multiSelectionActive = false;
this.eventBus.emit(new WarshipSelectionBoxCancelEvent());
}
}
if (
@@ -358,7 +402,19 @@ export class InputHandler {
this.eventBus.emit(new ConfirmGhostStructureEvent());
}
// Don't track zoom keys when a meta/ctrl modifier is held — that means
// the browser is handling its own zoom (cmd+/cmd-) and the keyup will
// never fire, which would leave the key stuck in activeKeys forever.
// Also covers numpad zoom shortcuts (Ctrl+NumpadAdd/NumpadSubtract).
const isBrowserZoomCombo =
(e.metaKey || e.ctrlKey) &&
(e.code === "Minus" ||
e.code === "Equal" ||
e.code === "NumpadAdd" ||
e.code === "NumpadSubtract");
if (
!isBrowserZoomCombo &&
[
this.keybinds.moveUp,
this.keybinds.moveDown,
@@ -372,17 +428,27 @@ export class InputHandler {
"ArrowRight",
"Minus",
"Equal",
"NumpadAdd",
"NumpadSubtract",
this.keybinds.attackRatioDown,
this.keybinds.attackRatioUp,
this.keybinds.centerCamera,
"ControlLeft",
"ControlRight",
"ShiftLeft",
"ShiftRight",
this.keybinds.shiftKey,
].includes(e.code)
) {
this.activeKeys.add(e.code);
}
// Shift = warship box selection mode.
// If a ghost structure is active, discard it first.
if (e.code === this.keybinds.shiftKey) {
if (this.uiState.ghostStructure !== null) {
this.setGhostStructure(null);
}
this.canvas.style.cursor = "crosshair";
}
});
window.addEventListener("keyup", (e) => {
const isTextInput = this.isTextInputTarget(e.target);
@@ -390,7 +456,25 @@ export class InputHandler {
return;
}
if (e.code === this.keybinds.toggleView) {
// When the meta (cmd) or ctrl key is released, any keys that were held
// simultaneously will have had their keyup swallowed by the browser
// (e.g. cmd+Plus for browser zoom). Clear zoom-related keys to
// prevent them staying stuck in activeKeys.
if (
e.code === "MetaLeft" ||
e.code === "MetaRight" ||
e.code === "ControlLeft" ||
e.code === "ControlRight"
) {
this.activeKeys.delete("Minus");
this.activeKeys.delete("Equal");
this.activeKeys.delete("NumpadAdd");
this.activeKeys.delete("NumpadSubtract");
this.activeKeys.delete(this.keybinds.zoomIn);
this.activeKeys.delete(this.keybinds.zoomOut);
}
if (this.keybindMatchesEvent(e, this.keybinds.toggleView)) {
e.preventDefault();
this.alternateView = false;
this.eventBus.emit(new AlternateViewEvent(false));
@@ -402,55 +486,73 @@ export class InputHandler {
this.eventBus.emit(new RefreshGraphicsEvent());
}
if (e.code === this.keybinds.boatAttack) {
if (this.keybindMatchesEvent(e, this.keybinds.boatAttack)) {
e.preventDefault();
this.eventBus.emit(new DoBoatAttackEvent());
}
if (e.code === this.keybinds.groundAttack) {
if (this.keybindMatchesEvent(e, this.keybinds.groundAttack)) {
e.preventDefault();
this.eventBus.emit(new DoGroundAttackEvent());
}
if (e.code === this.keybinds.attackRatioDown) {
if (this.keybindMatchesEvent(e, this.keybinds.attackRatioDown)) {
e.preventDefault();
const increment = this.userSettings.attackRatioIncrement();
this.eventBus.emit(new AttackRatioEvent(-increment));
}
if (e.code === this.keybinds.attackRatioUp) {
if (this.keybindMatchesEvent(e, this.keybinds.attackRatioUp)) {
e.preventDefault();
const increment = this.userSettings.attackRatioIncrement();
this.eventBus.emit(new AttackRatioEvent(increment));
}
if (e.code === this.keybinds.centerCamera) {
if (this.keybindMatchesEvent(e, this.keybinds.centerCamera)) {
e.preventDefault();
this.eventBus.emit(new CenterCameraEvent());
}
if (e.code === this.keybinds.selectAllWarships) {
e.preventDefault();
this.eventBus.emit(new SelectAllWarshipsEvent());
}
// Two-phase build keybind matching: exact code match first, then digit/Numpad alias.
const matchedBuild = this.resolveBuildKeybind(e.code);
const matchedBuild = this.resolveBuildKeybind(e.code, e.shiftKey);
if (matchedBuild !== null) {
e.preventDefault();
this.setGhostStructure(matchedBuild);
}
if (e.code === this.keybinds.swapDirection) {
if (this.keybindMatchesEvent(e, this.keybinds.requestAlliance)) {
e.preventDefault();
this.eventBus.emit(new DoRequestAllianceEvent());
}
if (this.keybindMatchesEvent(e, this.keybinds.breakAlliance)) {
e.preventDefault();
this.eventBus.emit(new DoBreakAllianceEvent());
}
if (this.keybindMatchesEvent(e, this.keybinds.swapDirection)) {
e.preventDefault();
const nextDirection = !this.uiState.rocketDirectionUp;
this.eventBus.emit(new SwapRocketDirectionEvent(nextDirection));
}
if (!e.repeat && e.code === this.keybinds.pauseGame) {
if (!e.repeat && this.keybindMatchesEvent(e, this.keybinds.pauseGame)) {
e.preventDefault();
this.eventBus.emit(new TogglePauseIntentEvent());
}
if (!e.repeat && e.code === this.keybinds.gameSpeedUp) {
if (!e.repeat && this.keybindMatchesEvent(e, this.keybinds.gameSpeedUp)) {
e.preventDefault();
this.eventBus.emit(new GameSpeedUpIntentEvent());
}
if (!e.repeat && e.code === this.keybinds.gameSpeedDown) {
if (
!e.repeat &&
this.keybindMatchesEvent(e, this.keybinds.gameSpeedDown)
) {
e.preventDefault();
this.eventBus.emit(new GameSpeedDownIntentEvent());
}
@@ -463,6 +565,15 @@ export class InputHandler {
}
this.activeKeys.delete(e.code);
// Reset crosshair when Shift is released (unless selection box or multi-selection still active)
if (
e.code === this.keybinds.shiftKey &&
!this.selectionBoxActive &&
!this.multiSelectionActive
) {
this.canvas.style.cursor = "";
}
});
}
@@ -488,7 +599,37 @@ export class InputHandler {
this.lastPointerDownY = event.clientY;
this.eventBus.emit(new MouseDownEvent(event.clientX, event.clientY));
// Start long-press timer for touch devices
if (event.pointerType === "touch") {
this.longPressActive = false;
if (this.longPressTimer !== null) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
this.longPressTimer = setTimeout(() => {
this.longPressTimer = null;
this.longPressActive = true;
this.canvas.style.cursor = "crosshair";
this.eventBus.emit(
new TouchLongPressStartEvent(
this.lastPointerDownX,
this.lastPointerDownY,
),
);
}, this.LONG_PRESS_MS);
}
} else if (this.pointers.size === 2) {
// Second finger down — cancel any pending long-press to avoid
// triggering selection mode mid-pinch
if (this.longPressTimer !== null) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
if (this.longPressActive) {
this.longPressActive = false;
this.canvas.style.cursor = "";
}
this.lastPinchDistance = this.getPinchDistance();
}
}
@@ -505,11 +646,50 @@ export class InputHandler {
this.pointerDown = false;
this.pointers.clear();
// Clean up long-press state
if (this.longPressTimer !== null) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
const wasLongPress = this.longPressActive;
this.longPressActive = false;
if (wasLongPress) {
this.canvas.style.cursor = "";
// If long-press fired but no drag happened (selectionBoxActive is false),
// suppress the tap so we don't emit a spurious TouchEvent
if (!this.selectionBoxActive) {
this.suppressNextTap = true;
}
}
// Complete selection box if it was active
if (this.selectionBoxActive) {
this.selectionBoxActive = false;
const dist =
Math.abs(event.clientX - this.lastPointerDownX) +
Math.abs(event.clientY - this.lastPointerDownY);
if (dist >= this.DRAG_THRESHOLD_PX) {
this.eventBus.emit(
new WarshipSelectionBoxCompleteEvent(
this.lastPointerDownX,
this.lastPointerDownY,
event.clientX,
event.clientY,
),
);
return;
} else {
this.eventBus.emit(new WarshipSelectionBoxCancelEvent());
}
}
if (this.isModifierKeyPressed(event)) {
this.suppressNextTap = false;
this.eventBus.emit(new ShowBuildMenuEvent(event.clientX, event.clientY));
return;
}
if (this.isAltKeyPressed(event)) {
this.suppressNextTap = false;
this.eventBus.emit(new ShowEmojiMenuEvent(event.clientX, event.clientY));
return;
}
@@ -517,14 +697,23 @@ export class InputHandler {
const dist =
Math.abs(event.x - this.lastPointerDownX) +
Math.abs(event.y - this.lastPointerDownY);
if (dist < 10) {
if (dist < this.DRAG_THRESHOLD_PX) {
if (event.pointerType === "touch") {
if (this.suppressNextTap) {
this.suppressNextTap = false;
event.preventDefault();
return;
}
this.eventBus.emit(new TouchEvent(event.x, event.y));
event.preventDefault();
return;
}
if (!this.userSettings.leftClickOpensMenu() || event.shiftKey) {
if (
!this.userSettings.leftClickOpensMenu() ||
event.shiftKey ||
this.gameView.inSpawnPhase() // No Radial Menu during spawn phase, only spawn point selection
) {
this.eventBus.emit(new MouseUpEvent(event.x, event.y));
} else {
this.eventBus.emit(new ContextMenuEvent(event.clientX, event.clientY));
@@ -537,8 +726,27 @@ export class InputHandler {
const realCtrl =
this.activeKeys.has("ControlLeft") ||
this.activeKeys.has("ControlRight");
const ratio = event.ctrlKey && !realCtrl ? 10 : 1; // Compensate pinch-zoom low sensitivity
this.eventBus.emit(new ZoomEvent(event.x, event.y, event.deltaY * ratio));
if (event.ctrlKey) {
if (!realCtrl) {
// Pinch-to-zoom gesture (trackpad): small deltas, amplify.
// Ignore large deltas — those are browser zoom shortcuts (cmd+/cmd-)
// which fire synthetic wheel events we don't want to handle.
if (Math.abs(event.deltaY) <= 10) {
this.eventBus.emit(
new ZoomEvent(event.x, event.y, event.deltaY * 10),
);
}
}
// Always return when ctrlKey is set — whether it's a real ctrl scroll,
// a pinch gesture, or a browser zoom event, none should reach the
// regular scroll path below.
return;
}
// Regular scroll wheel: ignore tiny residual momentum events that macOS
// keeps sending after a gesture ends (especially after browser zoom changes
// devicePixelRatio, which can cause these to accumulate into runaway zoom).
if (Math.abs(event.deltaY) < 2) return;
this.eventBus.emit(new ZoomEvent(event.x, event.y, event.deltaY));
}
}
@@ -572,7 +780,36 @@ export class InputHandler {
const deltaX = event.clientX - this.lastPointerX;
const deltaY = event.clientY - this.lastPointerY;
this.eventBus.emit(new DragEvent(deltaX, deltaY));
// Cancel long-press if finger moved significantly before timer fires
if (this.longPressTimer !== null) {
const moveDist =
Math.abs(event.clientX - this.lastPointerDownX) +
Math.abs(event.clientY - this.lastPointerDownY);
if (moveDist >= this.DRAG_THRESHOLD_PX) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
}
// If shift is held OR touch long-press is active OR selection box already
// started, continue emitting selection box updates
if (
this.selectionBoxActive ||
this.activeKeys.has(this.keybinds.shiftKey) ||
this.longPressActive
) {
this.selectionBoxActive = true;
this.eventBus.emit(
new WarshipSelectionBoxUpdateEvent(
this.lastPointerDownX,
this.lastPointerDownY,
event.clientX,
event.clientY,
),
);
} else {
this.eventBus.emit(new DragEvent(deltaX, deltaY));
}
this.lastPointerX = event.clientX;
this.lastPointerY = event.clientY;
@@ -592,6 +829,9 @@ export class InputHandler {
private onContextMenu(event: MouseEvent) {
event.preventDefault();
if (this.gameView.inSpawnPhase()) {
return;
}
if (this.uiState.ghostStructure !== null) {
this.setGhostStructure(null);
return;
@@ -604,6 +844,27 @@ export class InputHandler {
this.eventBus.emit(new GhostStructureChangedEvent(ghostStructure));
}
/**
* Parses a keybind value that may include a "Shift+" prefix.
* e.g. "Shift+KeyB" → { shift: true, code: "KeyB" }
* "KeyB" → { shift: false, code: "KeyB" }
*/
private parseKeybind(value: string): { shift: boolean; code: string } {
if (value?.startsWith("Shift+")) {
return { shift: true, code: value.slice(6) };
}
return { shift: false, code: value };
}
/**
* Returns true if the keyboard event matches the given keybind value,
* including optional Shift+ prefix support.
*/
private keybindMatchesEvent(e: KeyboardEvent, keybindValue: string): boolean {
const parsed = this.parseKeybind(keybindValue);
return e.code === parsed.code && e.shiftKey === parsed.shift;
}
/**
* Extracts the digit character from KeyboardEvent.code.
* Codes look like "Digit0".."Digit9" (6 chars, digit at index 5) and
@@ -626,17 +887,25 @@ export class InputHandler {
}
/** Strict equality only: used for first-pass exact KeyboardEvent.code match. */
private buildKeybindMatches(code: string, keybindValue: string): boolean {
return code === keybindValue;
private buildKeybindMatches(
code: string,
shiftKey: boolean,
keybindValue: string,
): boolean {
const parsed = this.parseKeybind(keybindValue);
return code === parsed.code && shiftKey === parsed.shift;
}
/** Digit/Numpad alias match: used only when no exact match was found. */
private buildKeybindMatchesDigit(
code: string,
shiftKey: boolean,
keybindValue: string,
): boolean {
const parsed = this.parseKeybind(keybindValue);
if (shiftKey !== parsed.shift) return false;
const digit = this.digitFromKeyCode(code);
const bindDigit = this.digitFromKeyCode(keybindValue);
const bindDigit = this.digitFromKeyCode(parsed.code);
return digit !== null && bindDigit !== null && digit === bindDigit;
}
@@ -644,7 +913,10 @@ export class InputHandler {
* Resolves a keyup code to a build action: exact code match first, then digit/Numpad alias.
* Returns the UnitType to set as ghost, or null if no build keybind matched.
*/
private resolveBuildKeybind(code: string): PlayerBuildableUnitType | null {
private resolveBuildKeybind(
code: string,
shiftKey: boolean,
): PlayerBuildableUnitType | null {
const buildKeybinds: ReadonlyArray<{
key: string;
type: PlayerBuildableUnitType;
@@ -661,10 +933,12 @@ export class InputHandler {
{ key: "buildMIRV", type: UnitType.MIRV },
];
for (const { key, type } of buildKeybinds) {
if (this.buildKeybindMatches(code, this.keybinds[key])) return type;
if (this.buildKeybindMatches(code, shiftKey, this.keybinds[key]))
return type;
}
for (const { key, type } of buildKeybinds) {
if (this.buildKeybindMatchesDigit(code, this.keybinds[key])) return type;
if (this.buildKeybindMatchesDigit(code, shiftKey, this.keybinds[key]))
return type;
}
return null;
}
+74 -4
View File
@@ -19,7 +19,7 @@ import {
LobbyInfoEvent,
PublicGameInfo,
} from "../core/Schemas";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { getRuntimeClientServerConfig } from "../core/configuration/ConfigLoader";
import {
Difficulty,
GameMapSize,
@@ -160,7 +160,7 @@ export class JoinLobbyModal extends BaseModal {
class="p-6 lg:p-6 border-t border-white/10 bg-black/20 shrink-0"
>
<button
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-sky-600 hover:bg-sky-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all shadow-lg shadow-sky-900/20 hover:shadow-sky-900/40 hover:-translate-y-0.5 active:translate-y-0 disabled:transform-none"
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-[#0073b7] hover:bg-sky-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all shadow-lg shadow-sky-900/20 hover:shadow-sky-900/40 hover:-translate-y-0.5 active:translate-y-0 disabled:transform-none"
disabled
>
${translateText("private_lobby.joined_waiting")}
@@ -374,6 +374,11 @@ export class JoinLobbyModal extends BaseModal {
);
}
public confirmBeforeClose(): boolean {
if (!this.currentLobbyId) return true;
return confirm(translateText("host_modal.leave_confirmation"));
}
protected onClose(): void {
this.clearCountdownTimer();
this.stopLobbyUpdates();
@@ -547,6 +552,13 @@ export class JoinLobbyModal extends BaseModal {
.value=${translateText("common.disabled")}
></lobby-config-item>`,
);
if (c.waterNukes)
cards.push(
html`<lobby-config-item
.label=${translateText("public_game_modifier.water_nukes_label")}
.value=${translateText("common.enabled")}
></lobby-config-item>`,
);
if ((isTeam && !c.donateGold) || (!isTeam && c.donateGold))
cards.push(
html`<lobby-config-item
@@ -624,7 +636,7 @@ export class JoinLobbyModal extends BaseModal {
${cards}
</div>`
: html``}
${this.renderDisabledUnits()}
${this.renderDisabledUnits()} ${this.renderHostCheats()}
`;
}
@@ -679,6 +691,64 @@ export class JoinLobbyModal extends BaseModal {
`;
}
private renderHostCheats(): TemplateResult {
if (!this.gameConfig?.hostCheats) {
return html``;
}
const hc = this.gameConfig.hostCheats;
const items: TemplateResult[] = [];
if (hc.infiniteGold)
items.push(
html`<span
class="px-2 py-1 bg-yellow-500/20 text-yellow-200 text-xs rounded font-bold border border-yellow-500/30"
>
${translateText("host_modal.infinite_gold")}
</span>`,
);
if (hc.infiniteTroops)
items.push(
html`<span
class="px-2 py-1 bg-yellow-500/20 text-yellow-200 text-xs rounded font-bold border border-yellow-500/30"
>
${translateText("host_modal.infinite_troops")}
</span>`,
);
if (hc.goldMultiplier)
items.push(
html`<span
class="px-2 py-1 bg-yellow-500/20 text-yellow-200 text-xs rounded font-bold border border-yellow-500/30"
>
${translateText("host_modal.gold_multiplier")}: x${hc.goldMultiplier}
</span>`,
);
if (hc.startingGold)
items.push(
html`<span
class="px-2 py-1 bg-yellow-500/20 text-yellow-200 text-xs rounded font-bold border border-yellow-500/30"
>
${translateText("private_lobby.starting_gold")}:
${parseFloat((hc.startingGold / 1_000_000).toPrecision(12))}M
</span>`,
);
if (items.length === 0) return html``;
return html`
<div
class="mt-4 mb-6 p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg"
>
<div
class="text-xs font-bold text-yellow-400 uppercase tracking-widest mb-2"
>
${translateText("private_lobby.host_cheats")}
</div>
<div class="flex flex-wrap gap-2">${items}</div>
</div>
`;
}
// --- Lobby event handling ---
private updateFromLobby(lobby: GameInfo | PublicGameInfo) {
@@ -897,7 +967,7 @@ export class JoinLobbyModal extends BaseModal {
}
private async checkActiveLobby(lobbyId: string): Promise<boolean> {
const config = await getServerConfigFromClient();
const config = await getRuntimeClientServerConfig();
const url = `/${config.workerPath(lobbyId)}/api/game/${lobbyId}/exists`;
const response = await fetch(url, {
+2
View File
@@ -72,6 +72,7 @@ export class LangSelector extends LitElement {
if (supported.has(lang)) return lang;
const base = lang.slice(0, 2);
if (supported.has(base)) return base;
const candidates = Array.from(supported).filter((key) =>
key.startsWith(base),
);
@@ -226,6 +227,7 @@ export class LangSelector extends LitElement {
"o-modal",
"o-button",
"territory-patterns-modal",
"store-modal",
"pattern-input",
"fluent-slider",
"news-modal",
+2 -2
View File
@@ -1,4 +1,4 @@
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { getRuntimeClientServerConfig } from "../core/configuration/ConfigLoader";
import { PublicGames, PublicGamesSchema } from "../core/Schemas";
interface LobbySocketOptions {
@@ -35,7 +35,7 @@ export class PublicLobbySocket {
this.stopped = false;
this.wsConnectionAttempts = 0;
// Get config to determine number of workers, then pick a random one
const config = await getServerConfigFromClient();
const config = await getRuntimeClientServerConfig();
this.workerPath = getRandomWorkerPath(config.numWorkers());
this.connectWebSocket();
}
+83 -69
View File
@@ -9,9 +9,13 @@ import {
PublicGameInfo,
} from "../core/Schemas";
import { GameEnv } from "../core/configuration/Config";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { getRuntimeClientServerConfig } from "../core/configuration/ConfigLoader";
import { GameType } from "../core/game/Game";
import { UserSettings } from "../core/game/UserSettings";
import {
DARK_MODE_KEY,
USER_SETTINGS_CHANGED_EVENT,
UserSettings,
} from "../core/game/UserSettings";
import "./AccountModal";
import { getUserMe } from "./Api";
import { userAuth } from "./Auth";
@@ -27,8 +31,8 @@ import "./GameModeSelector";
import { GameModeSelector } from "./GameModeSelector";
import { GameStartingModal } from "./GameStartingModal";
import "./GoogleAdElement";
import { GutterAds } from "./GutterAds";
import { HelpModal } from "./HelpModal";
import "./HomepagePromos";
import { HostLobbyModal as HostPrivateLobbyModal } from "./HostLobbyModal";
import { JoinLobbyModal } from "./JoinLobbyModal";
import "./LangSelector";
@@ -41,6 +45,8 @@ import { initNavigation } from "./Navigation";
import "./NewsModal";
import "./PatternInput";
import "./SinglePlayerModal";
import { StoreModal } from "./Store";
import "./TerritoryPatternsModal";
import { TerritoryPatternsModal } from "./TerritoryPatternsModal";
import { TokenLoginModal } from "./TokenLoginModal";
import {
@@ -56,9 +62,9 @@ import {
isInIframe,
translateText,
} from "./Utils";
import "./components/DesktopNavBar";
import "./components/Footer";
import "./components/HomeFooterAd";
import "./components/MainLayout";
import "./components/MobileNavBar";
import "./components/PlayPage";
@@ -99,17 +105,9 @@ function updateAccountNavButton(userMeResponse: UserMeResponse | false) {
// If the avatar fails to load (bad URL / CDN issue / offline), fall back
// to the default sign-in UI instead of leaving a broken image.
avatarEl.onerror = () => {
// Only handle if this is the latest update
if (avatarEl._navToken !== navToken) return;
avatarEl.src = "";
// If the user is still logged in via email, show the email badge state.
const email =
userMeResponse !== false ? userMeResponse.user.email : undefined;
if (email) {
showEmailLoggedIn();
} else {
showSignIn();
}
avatarEl.onerror = null;
avatarEl.src = "https://cdn.discordapp.com/embed/avatars/0.png";
};
avatarEl.onload = () => {
// Only handle if this is the latest update
@@ -246,11 +244,11 @@ class Client {
private joinModal: JoinLobbyModal;
private gameModeSelector: GameModeSelector;
private userSettings: UserSettings = new UserSettings();
private patternsModal: TerritoryPatternsModal;
private storeModal: StoreModal;
private tokenLoginModal: TokenLoginModal;
private matchmakingModal: MatchmakingModal;
private mostRecentJoinEvent: number;
private gutterAds: GutterAds;
private turnstileTokenPromise: Promise<{
token: string;
createdAt: number;
@@ -310,11 +308,6 @@ class Client {
}
});
const gutterAds = document.querySelector("gutter-ads");
if (!(gutterAds instanceof GutterAds))
throw new Error("Missing gutter-ads");
this.gutterAds = gutterAds;
document.addEventListener("join-lobby", this.handleJoinLobby.bind(this));
document.addEventListener("leave-lobby", this.handleLeaveLobby.bind(this));
document.addEventListener("kick-player", this.handleKickPlayer.bind(this));
@@ -360,30 +353,22 @@ class Client {
});
});
this.patternsModal = document.getElementById(
this.storeModal = document.getElementById("page-item-store") as StoreModal;
if (!this.storeModal || !(this.storeModal instanceof StoreModal)) {
console.warn("Store modal element not found");
}
const patternsModal = document.getElementById(
"territory-patterns-modal",
) as TerritoryPatternsModal;
if (
!this.patternsModal ||
!(this.patternsModal instanceof TerritoryPatternsModal)
) {
console.warn("Territory patterns modal element not found");
if (!patternsModal || !(patternsModal instanceof TerritoryPatternsModal)) {
console.warn("Patterns modal element not found");
}
// Attach listener to any pattern-input component
document.querySelectorAll("pattern-input").forEach((patternInput) => {
patternInput.addEventListener("pattern-input-click", () => {
// Open the Store page which contains the patterns UI
window.showPage?.("page-item-store");
const skinStoreModal = document.getElementById(
"page-item-store",
) as HTMLElement & { open?: (opts: any) => void };
if (skinStoreModal) {
skinStoreModal.classList.remove("hidden");
if (typeof skinStoreModal.open === "function") {
skinStoreModal.open({ showOnlyOwned: true });
}
}
patternsModal.open();
});
});
@@ -392,29 +377,20 @@ class Client {
if (mobilePat) mobilePat.style.display = "none";
}
if (
!this.patternsModal ||
!(this.patternsModal instanceof TerritoryPatternsModal)
) {
console.warn("Territory patterns modal element not found");
if (!this.storeModal || !(this.storeModal instanceof StoreModal)) {
console.warn("Store modal element not found");
}
// We no longer need to manually manage the preview button as PatternInput handles it component-side.
// However, we still want to ensure the modal can be opened.
// The setupPatternInput above handles the click event for the new buttons.
this.patternsModal.refresh();
// Listen for pattern selection to update any other listeners if needed,
// though PatternInput handles its own updates via window event.
this.patternsModal.addEventListener("pattern-selected", () => {
// PatternInput components will update themselves.
});
this.storeModal.refresh();
window.addEventListener("showPage", (e: any) => {
if (typeof e?.detail === "string" && e.detail === "page-play") {
setTimeout(() => {
this.patternsModal.refresh();
this.storeModal.refresh();
}, 50);
}
});
@@ -504,11 +480,23 @@ class Client {
this.joinModal.eventBus = this.eventBus;
}
if (this.userSettings.darkMode()) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
const applyDarkMode = (isDark: boolean) => {
if (isDark) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
};
applyDarkMode(this.userSettings.darkMode());
globalThis.addEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${DARK_MODE_KEY}`,
(e: CustomEvent<string>) => {
const isDark = e.detail === "true";
applyDarkMode(isDark);
},
);
// Attempt to join lobby
if (document.readyState === "loading") {
@@ -646,14 +634,26 @@ class Client {
return;
}
const patternName = params.get("cosmetic");
if (!patternName) {
const type = params.get("type");
if (type === "currency_pack") {
alertAndStrip(translateText("store.currency_pack_purchase_success"));
return;
}
const cosmeticName = params.get("cosmetic");
if (!cosmeticName) {
alert("Something went wrong. Please contact support.");
console.error("purchase-completed but no pattern name");
return;
}
this.userSettings.setSelectedPatternName(patternName);
const setCosmetic = () => {
if (cosmeticName.startsWith("pattern:")) {
this.userSettings.setSelectedPatternName(cosmeticName);
} else if (cosmeticName.startsWith("flag:")) {
this.userSettings.setFlag(cosmeticName);
}
};
const token = params.get("login-token");
if (token) {
@@ -661,12 +661,13 @@ class Client {
window.addEventListener("beforeunload", () => {
// The page reloads after token login, so we need to save the pattern name
// in case it is unset during reload.
this.userSettings.setSelectedPatternName(patternName);
setCosmetic();
});
this.tokenLoginModal.openWithToken(token);
} else {
alertAndStrip(`purchase succeeded: ${patternName}`);
this.patternsModal.refresh();
alertAndStrip(`purchase succeeded: ${cosmeticName}`);
setCosmetic();
this.storeModal.refresh();
}
return;
}
@@ -701,7 +702,7 @@ class Client {
const affiliateCode = decodedHash.replace("#affiliate=", "");
strip();
if (affiliateCode) {
this.patternsModal?.open(affiliateCode);
this.storeModal?.open(affiliateCode);
}
}
if (decodedHash.startsWith("#refresh")) {
@@ -736,6 +737,7 @@ class Client {
private async handleJoinLobby(event: CustomEvent<JoinLobbyEvent>) {
const lobby = event.detail;
this.mostRecentJoinEvent = event.timeStamp;
if (this.usernameInput && !this.usernameInput.validateOrShowError()) {
return;
}
@@ -749,22 +751,33 @@ class Client {
if (lobby.source === "public") {
this.joinModal?.open(lobby.gameID, lobby.publicLobbyInfo);
}
const config = await getServerConfigFromClient();
const config = await getRuntimeClientServerConfig();
// Only update URL immediately for private lobbies, not public ones
if (lobby.source !== "public") {
this.updateJoinUrlForShare(lobby.gameID, config);
}
this.lobbyHandle = joinLobby(this.eventBus, {
const auth = await userAuth();
const playerRole = auth !== false ? (auth.claims.role ?? null) : null;
const newLobbyHandle = joinLobby(this.eventBus, {
gameID: lobby.gameID,
serverConfig: config,
cosmetics: await getPlayerCosmeticsRefs(),
turnstileToken: await this.getTurnstileToken(lobby),
playerName: this.usernameInput?.getUsername() ?? genAnonUsername(),
playerClanTag: this.usernameInput?.getClanTag() ?? null,
playerRole,
gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info,
gameRecord: lobby.gameRecord,
});
if (this.mostRecentJoinEvent !== event.timeStamp) {
newLobbyHandle.stop(true);
console.warn("Join requested, but was superseded");
return;
}
this.lobbyHandle = newLobbyHandle;
this.lobbyHandle.prestart.then(() => {
console.log("Closing modals");
document.getElementById("settings-button")?.classList.add("hidden");
@@ -785,6 +798,7 @@ class Client {
"user-setting",
"troubleshooting-modal",
"territory-patterns-modal",
"store-modal",
"language-modal",
"news-modal",
"flag-input-modal",
@@ -793,7 +807,7 @@ class Client {
"token-login",
"matchmaking-modal",
"lang-selector",
"gutter-ads",
"homepage-promos",
].forEach((tag) => {
const modal = document.querySelector(tag) as HTMLElement & {
close?: () => void;
@@ -857,7 +871,7 @@ class Client {
private updateJoinUrlForShare(
lobbyId: string,
config: Awaited<ReturnType<typeof getServerConfigFromClient>>,
config: Awaited<ReturnType<typeof getRuntimeClientServerConfig>>,
) {
const lobbyIdHidden = !this.userSettings.lobbyIdVisibility();
const targetUrl = lobbyIdHidden
@@ -930,7 +944,7 @@ class Client {
private async getTurnstileToken(
lobby: JoinLobbyEvent,
): Promise<string | null> {
const config = await getServerConfigFromClient();
const config = await getRuntimeClientServerConfig();
if (
config.env() === GameEnv.Dev ||
lobby.gameStartInfo?.config.gameType === GameType.Singleplayer
@@ -1008,7 +1022,7 @@ async function getTurnstileToken(): Promise<{
throw new Error("Failed to load Turnstile script");
}
const config = await getServerConfigFromClient();
const config = await getRuntimeClientServerConfig();
const widgetId = window.turnstile.render("#turnstile-container", {
sitekey: config.turnstileSiteKey(),
size: "normal",
+3 -4
View File
@@ -1,12 +1,11 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { UserMeResponse } from "../core/ApiSchemas";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { getRuntimeClientServerConfig } from "../core/configuration/ConfigLoader";
import { getUserMe, hasLinkedAccount } from "./Api";
import { getPlayToken } from "./Auth";
import { BaseModal } from "./components/BaseModal";
import "./components/Difficulties";
import "./components/PatternButton";
import { modalHeader } from "./components/ui/ModalHeader";
import { JoinLobbyEvent } from "./Main";
import { translateText } from "./Utils";
@@ -87,7 +86,7 @@ export class MatchmakingModal extends BaseModal {
}
private async connect() {
const config = await getServerConfigFromClient();
const config = await getRuntimeClientServerConfig();
const instanceId = await MatchmakingModal.getInstanceId();
this.socket = new WebSocket(
@@ -210,7 +209,7 @@ export class MatchmakingModal extends BaseModal {
if (this.gameID === null) {
return;
}
const config = await getServerConfigFromClient();
const config = await getRuntimeClientServerConfig();
const url = `/${config.workerPath(this.gameID)}/api/game/${this.gameID}/exists`;
const response = await fetch(url, {
+12 -3
View File
@@ -19,11 +19,20 @@ export function initNavigation() {
// Close mobile sidebar if a nav item was clicked
closeMobileSidebar();
// Hide only the currently visible modal
// Close the currently visible modal properly
const visibleModal = document.querySelector(".page-content:not(.hidden)");
if (visibleModal) {
visibleModal.classList.add("hidden");
visibleModal.classList.remove("block");
// If it's an open modal component, call close() for proper cleanup (onClose callback, etc.)
if (
typeof (visibleModal as any).isOpen === "function" &&
(visibleModal as any).isOpen() &&
typeof (visibleModal as any).close === "function"
) {
(visibleModal as any).close();
} else {
visibleModal.classList.add("hidden");
visibleModal.classList.remove("block");
}
}
// Handle page-play separately (it's not a page-content element)
+17 -7
View File
@@ -1,7 +1,11 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import {
PATTERN_KEY,
USER_SETTINGS_CHANGED_EVENT,
} from "../core/game/UserSettings";
import { PlayerPattern } from "../core/Schemas";
import { renderPatternPreview } from "./components/PatternButton";
import { renderPatternPreview } from "./components/PatternPreview";
import { getPlayerCosmetics } from "./Cosmetics";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import { translateText } from "./Utils";
@@ -46,9 +50,13 @@ export class PatternInput extends LitElement {
this.pattern = cosmetics.pattern ?? null;
if (!this.isConnected) return;
this.isLoading = false;
window.addEventListener("pattern-selected", this._onPatternSelected, {
signal: this._abortController.signal,
});
window.addEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${PATTERN_KEY}`,
this._onPatternSelected,
{
signal: this._abortController.signal,
},
);
}
disconnectedCallback() {
@@ -79,10 +87,10 @@ export class PatternInput extends LitElement {
}
const showSelect = this.showSelectLabel && this.getIsDefaultPattern();
this.style.setProperty("height", "3rem");
this.style.setProperty("height", "2.5rem");
this.style.setProperty(
"width",
showSelect ? "clamp(6.5rem, 28vw, 9.5rem)" : "3rem",
showSelect ? "clamp(3.25rem, 14vw, 4.75rem)" : "2.5rem",
);
}
@@ -136,7 +144,9 @@ export class PatternInput extends LitElement {
</span>
${showSelect
? html`<span
class="text-[10px] font-black text-white uppercase leading-none break-words w-full text-center px-1"
class="${this.adaptiveSize
? "text-[7px] leading-tight px-0.5"
: "text-[10px] leading-none break-words px-1"} font-black text-white uppercase w-full text-center"
>
${translateText("territory_patterns.select_skin")}
</span>`
+14 -9
View File
@@ -58,6 +58,7 @@ const DEFAULT_OPTIONS = {
startingGoldValue: undefined as number | undefined,
disabledUnits: [] as UnitType[],
disableAlliances: false,
waterNukes: false,
} as const;
@customElement("single-player-modal")
@@ -93,6 +94,7 @@ export class SinglePlayerModal extends BaseModal {
...DEFAULT_OPTIONS.disabledUnits,
];
@state() private disableAlliances: boolean = DEFAULT_OPTIONS.disableAlliances;
@state() private waterNukes: boolean = DEFAULT_OPTIONS.waterNukes;
private mapLoader = terrainMapFileLoader;
@@ -145,14 +147,7 @@ export class SinglePlayerModal extends BaseModal {
return;
}
const achievements = Array.isArray(userMe.player.achievements)
? userMe.player.achievements
: [];
const completions =
achievements.find(
(achievement) => achievement?.type === "singleplayer-map",
)?.data ?? [];
const completions = userMe.player.achievements.singleplayerMap;
const winsMap = new Map<GameMapType, Set<Difficulty>>();
for (const entry of completions) {
@@ -320,6 +315,10 @@ export class SinglePlayerModal extends BaseModal {
labelKey: "single_modal.disable_alliances",
checked: this.disableAlliances,
},
{
labelKey: "single_modal.water_nukes",
checked: this.waterNukes,
},
],
inputCards,
},
@@ -351,7 +350,7 @@ export class SinglePlayerModal extends BaseModal {
: null}
<button
@click=${this.startGame}
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-sky-600 hover:bg-sky-500 rounded-xl transition-all shadow-lg shadow-sky-900/20 hover:shadow-sky-900/40 hover:-translate-y-0.5 active:translate-y-0"
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-[#0073b7] hover:bg-sky-500 rounded-xl transition-all shadow-lg shadow-sky-900/20 hover:shadow-sky-900/40 hover:-translate-y-0.5 active:translate-y-0"
>
${translateText("single_modal.start")}
</button>
@@ -391,6 +390,7 @@ export class SinglePlayerModal extends BaseModal {
this.goldMultiplier !== DEFAULT_OPTIONS.goldMultiplier ||
this.startingGold !== DEFAULT_OPTIONS.startingGold ||
this.disableAlliances !== DEFAULT_OPTIONS.disableAlliances ||
this.waterNukes !== DEFAULT_OPTIONS.waterNukes ||
this.disabledUnits.length > 0
);
}
@@ -418,6 +418,7 @@ export class SinglePlayerModal extends BaseModal {
this.startingGold = DEFAULT_OPTIONS.startingGold;
this.startingGoldValue = DEFAULT_OPTIONS.startingGoldValue;
this.disableAlliances = DEFAULT_OPTIONS.disableAlliances;
this.waterNukes = DEFAULT_OPTIONS.waterNukes;
}
protected onOpen(): void {
@@ -500,6 +501,9 @@ export class SinglePlayerModal extends BaseModal {
case "single_modal.disable_alliances":
this.disableAlliances = checked;
break;
case "single_modal.water_nukes":
this.waterNukes = checked;
break;
default:
break;
}
@@ -707,6 +711,7 @@ export class SinglePlayerModal extends BaseModal {
}
: {}),
...(this.disableAlliances ? { disableAlliances: true } : {}),
...(this.waterNukes ? { waterNukes: true } : {}),
},
lobbyCreatedAt: Date.now(), // ms; server should be authoritative in MP
},
+250
View File
@@ -0,0 +1,250 @@
import type { TemplateResult } from "lit";
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { UserMeResponse } from "../core/ApiSchemas";
import { Cosmetics } from "../core/CosmeticSchemas";
import { UserSettings } from "../core/game/UserSettings";
import { BaseModal } from "./components/BaseModal";
import "./components/CosmeticButton";
import "./components/NotLoggedInWarning";
import { modalHeader } from "./components/ui/ModalHeader";
import {
fetchCosmetics,
purchaseCosmetic,
resolveCosmetics,
} from "./Cosmetics";
import { translateText } from "./Utils";
@customElement("store-modal")
export class StoreModal extends BaseModal {
@state() private activeTab: "patterns" | "flags" | "packs" = "patterns";
private cosmetics: Cosmetics | null = null;
private isActive = false;
private affiliateCode: string | null = null;
private userMeResponse: UserMeResponse | false = false;
connectedCallback() {
super.connectedCallback();
document.addEventListener(
"userMeResponse",
(event: CustomEvent<UserMeResponse | false>) => {
this.onUserMe(event.detail);
},
);
}
async onUserMe(userMeResponse: UserMeResponse | false) {
this.userMeResponse = userMeResponse;
this.cosmetics = await fetchCosmetics();
this.refresh();
}
private renderHeader(): TemplateResult {
return html`
${modalHeader({
title: translateText("store.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
rightContent: html`<not-logged-in-warning></not-logged-in-warning>`,
})}
<div class="flex items-center gap-2 justify-center pt-2">
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "packs"
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
@click=${() => (this.activeTab = "packs")}
>
${translateText("store.packs")}
</button>
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "patterns"
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
@click=${() => (this.activeTab = "patterns")}
>
${translateText("store.patterns")}
</button>
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "flags"
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
@click=${() => (this.activeTab = "flags")}
>
${translateText("store.flags")}
</button>
</div>
`;
}
private renderPatternGrid(): TemplateResult {
const items = resolveCosmetics(
this.cosmetics,
this.userMeResponse,
this.affiliateCode,
).filter(
(r) =>
r.type === "pattern" &&
r.relationship !== "blocked" &&
r.relationship !== "owned",
);
if (items.length === 0) {
return html`<div
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
>
${translateText("store.no_skins")}
</div>`;
}
return html`
<div
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
>
${items.map(
(r) => html`
<cosmetic-button
.resolved=${r}
.onPurchase=${purchaseCosmetic}
></cosmetic-button>
`,
)}
</div>
`;
}
private renderFlagGrid(): TemplateResult {
const items = resolveCosmetics(
this.cosmetics,
this.userMeResponse,
this.affiliateCode,
).filter(
(r) =>
r.type === "flag" &&
r.relationship !== "blocked" &&
r.relationship !== "owned",
);
if (items.length === 0) {
return html`<div
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
>
${translateText("store.no_flags")}
</div>`;
}
const selectedFlag = new UserSettings().getFlag() ?? "";
return html`
<div
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
>
${items.map(
(r) => html`
<cosmetic-button
.resolved=${r}
.selected=${selectedFlag === r.key}
.onPurchase=${purchaseCosmetic}
></cosmetic-button>
`,
)}
</div>
`;
}
private renderPackGrid(): TemplateResult {
const items = resolveCosmetics(
this.cosmetics,
this.userMeResponse,
this.affiliateCode,
).filter((r) => r.type === "pack" && r.relationship === "purchasable");
if (items.length === 0) {
return html`<div
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
>
${translateText("store.no_packs")}
</div>`;
}
return html`
<div
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
>
${items.map(
(r) => html`
<cosmetic-button
.resolved=${r}
.onPurchase=${purchaseCosmetic}
></cosmetic-button>
`,
)}
</div>
`;
}
render() {
if (!this.isActive && !this.inline) return html``;
const content = html`
<div class="${this.modalContainerClass}">
${this.renderHeader()}
<div class="overflow-y-auto pr-2 custom-scrollbar mr-1">
${this.activeTab === "patterns"
? this.renderPatternGrid()
: this.activeTab === "flags"
? this.renderFlagGrid()
: this.renderPackGrid()}
</div>
</div>
`;
if (this.inline) {
return content;
}
return html`
<o-modal
id="storeModal"
title="${translateText("store.title")}"
?inline=${this.inline}
?hideHeader=${true}
?hideCloseButton=${true}
>
${content}
</o-modal>
`;
}
public async open(options?: string | { affiliateCode?: string }) {
if (this.isModalOpen) return;
this.isActive = true;
if (typeof options === "string") {
this.affiliateCode = options;
} else if (
options !== null &&
typeof options === "object" &&
!Array.isArray(options)
) {
this.affiliateCode = options.affiliateCode ?? null;
} else {
this.affiliateCode = null;
}
this.cosmetics ??= await fetchCosmetics();
await this.refresh();
super.open();
}
public close() {
this.isActive = false;
this.affiliateCode = null;
super.close();
}
public async refresh() {
this.requestUpdate();
}
}
+103 -241
View File
@@ -2,19 +2,23 @@ import type { TemplateResult } from "lit";
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { UserMeResponse } from "../core/ApiSchemas";
import { ColorPalette, Cosmetics, Pattern } from "../core/CosmeticSchemas";
import { UserSettings } from "../core/game/UserSettings";
import { Cosmetics } from "../core/CosmeticSchemas";
import {
PATTERN_KEY,
USER_SETTINGS_CHANGED_EVENT,
UserSettings,
} from "../core/game/UserSettings";
import { PlayerPattern } from "../core/Schemas";
import { hasLinkedAccount } from "./Api";
import { BaseModal } from "./components/BaseModal";
import "./components/Difficulties";
import "./components/PatternButton";
import "./components/CosmeticButton";
import "./components/NotLoggedInWarning";
import { modalHeader } from "./components/ui/ModalHeader";
import {
fetchCosmetics,
getPlayerCosmetics,
handlePurchase,
patternRelationship,
resolveCosmetics,
ResolvedCosmetic,
resolvedToPlayerPattern,
} from "./Cosmetics";
import { translateText } from "./Utils";
@@ -24,18 +28,10 @@ export class TerritoryPatternsModal extends BaseModal {
@state() private selectedPattern: PlayerPattern | null;
@state() private selectedColor: string | null = null;
@state() private activeTab: "patterns" | "colors" = "patterns";
@state() private showOnlyOwned: boolean = false;
@state() private search = "";
private cosmetics: Cosmetics | null = null;
private userSettings: UserSettings = new UserSettings();
private isActive = false;
private affiliateCode: string | null = null;
private userMeResponse: UserMeResponse | false = false;
private _onPatternSelected = async () => {
@@ -43,10 +39,6 @@ export class TerritoryPatternsModal extends BaseModal {
this.refresh();
};
constructor() {
super();
}
connectedCallback() {
super.connectedCallback();
document.addEventListener(
@@ -55,12 +47,18 @@ export class TerritoryPatternsModal extends BaseModal {
this.onUserMe(event.detail);
},
);
window.addEventListener("pattern-selected", this._onPatternSelected);
window.addEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${PATTERN_KEY}`,
this._onPatternSelected,
);
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("pattern-selected", this._onPatternSelected);
window.removeEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${PATTERN_KEY}`,
this._onPatternSelected,
);
}
private async updateFromSettings() {
@@ -76,184 +74,95 @@ export class TerritoryPatternsModal extends BaseModal {
this.refresh();
}
private renderTabNavigation(): TemplateResult {
return html`
${modalHeader({
title: translateText("territory_patterns.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
rightContent: !hasLinkedAccount(this.userMeResponse)
? html`<div class="flex items-center">
${this.renderNotLoggedInWarning()}
</div>`
: undefined,
})}
<!-- TEMP DISABlE TAB SWITCHING
<div class="flex items-center gap-2 justify-center">
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "patterns"
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
@click=${() => (this.activeTab = "patterns")}
>
${translateText("territory_patterns.title")}
</button>
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "colors"
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
@click=${() => (this.activeTab = "colors")}
>
${translateText("territory_patterns.colors")}
</button>
TEMP DISABlE TAB SWITCHING -->
`;
private includedInSearch(name: string): boolean {
const displayName = name.replace(/_/g, " ");
return displayName.toLowerCase().includes(this.search.toLowerCase());
}
private handleSearch(event: Event) {
this.search = (event.target as HTMLInputElement).value;
}
private renderPatternGrid(): TemplateResult {
const buttons: TemplateResult[] = [];
const patterns: (Pattern | null)[] = [
const items = resolveCosmetics(
this.cosmetics,
this.userMeResponse,
null,
...Object.values(this.cosmetics?.patterns ?? {}),
];
for (const pattern of patterns) {
const colorPalettes = pattern
? [...(pattern.colorPalettes ?? []), null]
: [null];
for (const colorPalette of colorPalettes) {
let rel = "owned";
if (pattern) {
rel = patternRelationship(
pattern,
colorPalette,
this.userMeResponse,
this.affiliateCode,
);
}
if (rel === "blocked") {
continue;
}
if (this.showOnlyOwned) {
if (rel !== "owned") continue;
} else {
// Store mode: hide owned items
if (rel === "owned") continue;
}
// Determine if this pattern/color is selected
const isDefaultPattern = pattern === null;
const isSelected =
(isDefaultPattern && this.selectedPattern === null) ||
(!isDefaultPattern &&
this.selectedPattern &&
this.selectedPattern.name === pattern?.name &&
(this.selectedPattern.colorPalette?.name ?? null) ===
(colorPalette?.name ?? null));
buttons.push(html`
<pattern-button
.pattern=${pattern}
.colorPalette=${this.cosmetics?.colorPalettes?.[
colorPalette?.name ?? ""
] ?? null}
.requiresPurchase=${rel === "purchasable"}
.selected=${isSelected}
.onSelect=${(p: PlayerPattern | null) => this.selectPattern(p)}
.onPurchase=${(p: Pattern, colorPalette: ColorPalette | null) =>
handlePurchase(p, colorPalette)}
></pattern-button>
`);
}
}
).filter(
(r) =>
r.type === "pattern" &&
r.relationship === "owned" &&
(r.cosmetic === null
? !this.search
: this.includedInSearch(r.cosmetic.name)),
);
return html`
<div class="flex flex-col">
<div class="pt-4 flex justify-center">
${hasLinkedAccount(this.userMeResponse)
? this.renderMySkinsButton()
: html``}
<div
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
>
${items.map((r) => {
const isSelected =
(r.cosmetic === null && this.selectedPattern === null) ||
(r.cosmetic !== null &&
this.selectedPattern?.name === r.cosmetic.name &&
(this.selectedPattern?.colorPalette?.name ?? null) ===
(r.colorPalette?.name ?? null));
return html`
<cosmetic-button
.resolved=${r}
.selected=${isSelected}
.onSelect=${(rc: ResolvedCosmetic) => this.selectCosmetic(rc)}
></cosmetic-button>
`;
})}
</div>
${!this.showOnlyOwned && buttons.length === 0
? html`<div
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
>
${translateText("territory_patterns.all_owned")}
</div>`
: html`
<div
class="flex flex-wrap gap-4 p-2 justify-center items-stretch content-start"
>
${buttons}
</div>
`}
</div>
`;
}
private renderMySkinsButton(): TemplateResult {
return html`<button
class="px-4 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-wider border mb-4 ${this
.showOnlyOwned
? "bg-blue-500/20 text-blue-400 border-blue-500/50 shadow-[0_0_10px_rgba(59,130,246,0.3)]"
: "bg-white/5 text-white/60 border-white/10 hover:bg-white/10 hover:text-white"}"
@click=${() => {
this.showOnlyOwned = !this.showOnlyOwned;
}}
>
${translateText("territory_patterns.show_only_owned")}
</button>`;
}
private renderNotLoggedInWarning(): TemplateResult {
return html`<button
class="px-4 py-2 text-xs font-bold uppercase tracking-wider transition-colors duration-200 rounded-lg bg-red-500/20 text-red-400 border border-red-500/30 cursor-pointer hover:bg-red-500/30"
@click=${() => {
this.close();
window.showPage?.("page-account");
}}
>
${translateText("territory_patterns.not_logged_in")}
</button>`;
}
private renderColorSwatchGrid(): TemplateResult {
const hexCodes = (
this.userMeResponse === false
? []
: (this.userMeResponse.player.flares ?? [])
)
.filter((flare) => flare.startsWith("color:"))
.map((flare) => flare.split(":")[1]);
return html`
<div class="flex flex-wrap gap-3 p-2 justify-center items-center">
${hexCodes.map(
(hexCode) => html`
<div
class="w-12 h-12 rounded-xl border-2 border-white/10 cursor-pointer transition-all duration-200 hover:scale-110 hover:shadow-[0_0_15px_rgba(255,255,255,0.3)] hover:border-white relative group"
style="background-color: ${hexCode};"
title="${hexCode}"
@click=${() => this.selectColor(hexCode)}
>
<div
class="absolute inset-0 rounded-xl ring-2 ring-inset ring-black/20"
></div>
</div>
`,
)}
</div>
`;
}
render() {
if (!this.isActive && !this.inline) return html``;
const content = html`
<div class="${this.modalContainerClass}">
${this.renderTabNavigation()}
<div class="overflow-y-auto pr-2 custom-scrollbar mr-1">
${this.activeTab === "patterns"
? this.renderPatternGrid()
: this.renderColorSwatchGrid()}
<div
class="relative flex flex-col border-b border-white/10 pb-4 shrink-0"
>
${modalHeader({
title: translateText("territory_patterns.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
rightContent: html`<not-logged-in-warning></not-logged-in-warning>`,
})}
<div class="md:flex items-center gap-2 justify-center mt-4">
<input
class="h-12 w-full max-w-md border border-white/10 bg-black/60
rounded-xl shadow-inner text-xl text-center focus:outline-none
focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 text-white placeholder-white/30 transition-all"
type="text"
placeholder=${translateText("territory_patterns.search")}
.value=${this.search}
@change=${this.handleSearch}
@keyup=${this.handleSearch}
/>
</div>
</div>
<div class="flex justify-center py-3 shrink-0">
<button
class="no-crazygames px-4 py-2 text-sm font-bold uppercase tracking-wider rounded-lg bg-blue-600 hover:bg-blue-700 text-white cursor-pointer transition-colors"
@click=${() => {
this.close();
window.showPage?.("page-item-store");
}}
>
${translateText("main.store")}
</button>
</div>
<div
class="flex-1 overflow-y-auto px-3 pb-3 scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent mr-1"
>
${this.renderPatternGrid()}
</div>
</div>
`;
@@ -265,9 +174,7 @@ export class TerritoryPatternsModal extends BaseModal {
return html`
<o-modal
id="territoryPatternsModal"
title="${this.activeTab === "patterns"
? translateText("territory_patterns.title")
: translateText("territory_patterns.colors")}"
title="${translateText("territory_patterns.title")}"
?inline=${this.inline}
?hideHeader=${true}
?hideCloseButton=${true}
@@ -277,38 +184,21 @@ export class TerritoryPatternsModal extends BaseModal {
`;
}
public async open(
options?: string | { affiliateCode?: string; showOnlyOwned?: boolean },
) {
this.isActive = true;
if (typeof options === "string") {
this.affiliateCode = options;
this.showOnlyOwned = false;
} else if (
options !== null &&
typeof options === "object" &&
!Array.isArray(options)
) {
this.affiliateCode = options.affiliateCode ?? null;
this.showOnlyOwned = options.showOnlyOwned ?? false;
} else {
this.affiliateCode = null;
this.showOnlyOwned = false;
}
protected async onOpen(): Promise<void> {
await this.refresh();
super.open();
}
public close() {
this.isActive = false;
this.affiliateCode = null;
super.close();
protected onClose(): void {
this.search = "";
}
private selectCosmetic(resolved: ResolvedCosmetic) {
if (resolved.type !== "pattern") return;
this.selectPattern(resolvedToPlayerPattern(resolved));
}
private selectPattern(pattern: PlayerPattern | null) {
this.selectedColor = null;
this.userSettings.setSelectedColor(undefined);
if (pattern === null) {
this.userSettings.setSelectedPatternName(undefined);
} else {
@@ -320,16 +210,11 @@ export class TerritoryPatternsModal extends BaseModal {
}
this.selectedPattern = pattern;
this.refresh();
// Dispatch event so Main.ts can refresh the preview button
this.dispatchEvent(new CustomEvent("pattern-selected", { bubbles: true }));
// Show popup/modal for skin selection
this.showSkinSelectedPopup();
// Close the skin store
this.close();
}
private showSkinSelectedPopup() {
// Use unified heads-up-message for feedback
let skinName = translateText("territory_patterns.pattern.default");
if (this.selectedPattern && this.selectedPattern.name) {
skinName = this.selectedPattern.name
@@ -353,29 +238,6 @@ export class TerritoryPatternsModal extends BaseModal {
);
}
private selectColor(hexCode: string) {
this.selectedPattern = null;
this.userSettings.setSelectedPatternName(undefined);
this.selectedColor = hexCode;
this.userSettings.setSelectedColor(hexCode);
this.refresh();
this.dispatchEvent(new CustomEvent("pattern-selected", { bubbles: true }));
this.close();
}
private renderColorPreview(
hexCode: string,
width: number,
height: number,
): TemplateResult {
return html`
<div
class="w-full h-full rounded"
style="background-color: ${hexCode};"
></div>
`;
}
public async refresh() {
this.requestUpdate();
}
-1
View File
@@ -3,7 +3,6 @@ import { customElement } from "lit/decorators.js";
import { tempTokenLogin } from "./Auth";
import { BaseModal } from "./components/BaseModal";
import "./components/Difficulties";
import "./components/PatternButton";
import { modalHeader } from "./components/ui/ModalHeader";
import { translateText } from "./Utils";
+2 -2
View File
@@ -160,7 +160,7 @@ export class SendHashEvent implements GameEvent {
export class MoveWarshipIntentEvent implements GameEvent {
constructor(
public readonly unitId: number,
public readonly unitIds: number[],
public readonly tile: number,
) {}
}
@@ -618,7 +618,7 @@ export class Transport {
private onMoveWarshipEvent(event: MoveWarshipIntentEvent) {
this.sendIntent({
type: "move_warship",
unitId: event.unitId,
unitIds: event.unitIds,
tile: event.tile,
});
}
+166 -247
View File
@@ -1,7 +1,7 @@
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { formatKeyForDisplay, translateText } from "../client/Utils";
import { UserSettings } from "../core/game/UserSettings";
import { getDefaultKeybinds, UserSettings } from "../core/game/UserSettings";
import "./components/baseComponents/setting/SettingKeybind";
import { SettingKeybind } from "./components/baseComponents/setting/SettingKeybind";
import "./components/baseComponents/setting/SettingNumber";
@@ -10,60 +10,21 @@ import "./components/baseComponents/setting/SettingSlider";
import "./components/baseComponents/setting/SettingToggle";
import { BaseModal } from "./components/BaseModal";
import { modalHeader } from "./components/ui/ModalHeader";
import "./FlagInputModal";
import { Platform } from "./Platform";
interface FlagInputModalElement extends HTMLElement {
open(): void;
returnTo?: string;
}
const isMac = Platform.isMac;
const DefaultKeybinds: Record<string, string> = {
toggleView: "Space",
coordinateGrid: "KeyM",
buildCity: "Digit1",
buildFactory: "Digit2",
buildPort: "Digit3",
buildDefensePost: "Digit4",
buildMissileSilo: "Digit5",
buildSamLauncher: "Digit6",
buildWarship: "Digit7",
buildAtomBomb: "Digit8",
buildHydrogenBomb: "Digit9",
buildMIRV: "Digit0",
attackRatioDown: "KeyT",
attackRatioUp: "KeyY",
boatAttack: "KeyB",
groundAttack: "KeyG",
swapDirection: "KeyU",
zoomOut: "KeyQ",
zoomIn: "KeyE",
centerCamera: "KeyC",
moveUp: "KeyW",
moveLeft: "KeyA",
moveDown: "KeyS",
moveRight: "KeyD",
modifierKey: isMac ? "MetaLeft" : "ControlLeft",
altKey: "AltLeft",
pauseGame: "KeyP",
gameSpeedUp: "Period",
gameSpeedDown: "Comma",
};
@customElement("user-setting")
export class UserSettingModal extends BaseModal {
private userSettings: UserSettings = new UserSettings();
private readonly defaultKeybinds = getDefaultKeybinds(Platform.isMac);
@state() private activeTab: "basic" | "keybinds" = "basic";
@state() private keySequence: string[] = [];
@state() private showEasterEggSettings = false;
@state() private keybinds: Record<
@state() private userKeybinds: Record<
string,
{ value: string | string[]; key: string }
{ value: string; key: string }
> = {};
connectedCallback() {
@@ -77,55 +38,39 @@ export class UserSettingModal extends BaseModal {
}
private loadKeybindsFromStorage() {
const savedKeybinds = localStorage.getItem("settings.keybinds");
if (!savedKeybinds) return;
try {
const parsed = JSON.parse(savedKeybinds);
if (
typeof parsed === "object" &&
parsed !== null &&
!Array.isArray(parsed)
) {
const isValid = Object.values(parsed).every((entry) => {
if (
typeof entry !== "object" ||
entry === null ||
Array.isArray(entry)
) {
return false;
}
if (!("key" in entry) || typeof (entry as any).key !== "string") {
return false;
}
if (!("value" in entry)) {
return false;
}
const value = (entry as any).value;
if (typeof value === "string") {
return true;
}
if (Array.isArray(value)) {
return value.every((v) => typeof v === "string");
}
return false;
});
if (isValid) {
this.keybinds = parsed;
} else {
console.warn(
"Invalid keybinds structure: entries must be objects with 'key' (string) and 'value' (string or string[]) properties. Ignoring saved data.",
);
}
} else {
console.warn(
"Invalid keybinds data: expected non-array object. Ignoring saved data.",
);
}
} catch (e) {
console.warn("Invalid keybinds JSON:", e);
const parsed = this.userSettings.parsedUserKeybinds();
if (Object.keys(parsed).length === 0) {
this.userKeybinds = {};
return;
}
const validated: Record<string, { value: string; key: string }> = {};
for (const [action, entry] of Object.entries(parsed)) {
if (typeof entry === "string") {
validated[action] = { value: entry, key: entry };
} else if (
typeof entry === "object" &&
entry !== null &&
!Array.isArray(entry)
) {
const rawValue = (entry as any).value ?? "Null";
const value = Array.isArray(rawValue)
? rawValue.find((v) => typeof v === "string")
: rawValue;
const rawKey = (entry as any).key ?? value;
const key = Array.isArray(rawKey)
? rawKey.find((v) => typeof v === "string")
: rawKey;
if (typeof value === "string" && typeof key === "string") {
validated[action] = { value, key };
}
}
}
this.userKeybinds = validated;
}
private handleKeybindChange(
@@ -138,11 +83,9 @@ export class UserSettingModal extends BaseModal {
) {
const { action, value, key, prevValue } = e.detail;
const activeKeybinds: Record<string, string> = { ...DefaultKeybinds };
for (const [k, v] of Object.entries(this.keybinds)) {
const normalizedValue = Array.isArray(v.value)
? v.value[0] || ""
: v.value;
const activeKeybinds = { ...this.defaultKeybinds };
for (const [k, v] of Object.entries(this.userKeybinds)) {
const normalizedValue = v.value;
if (normalizedValue === "Null") {
delete activeKeybinds[k];
} else {
@@ -194,32 +137,33 @@ export class UserSettingModal extends BaseModal {
}),
);
const element = this.renderRoot.querySelector(
const element = this.renderRoot.querySelector<SettingKeybind>(
`setting-keybind[action="${action}"]`,
) as SettingKeybind;
);
if (element) {
element.value = prevValue ?? DefaultKeybinds[action] ?? "";
element.value = prevValue ?? this.defaultKeybinds[action] ?? "";
element.requestUpdate();
}
return;
}
this.keybinds = { ...this.keybinds, [action]: { value: value, key: key } };
localStorage.setItem("settings.keybinds", JSON.stringify(this.keybinds));
this.userKeybinds = {
...this.userKeybinds,
[action]: { value: value, key: key },
};
this.userSettings.setKeybinds(this.userKeybinds);
}
private getKeyValue(action: string): string | undefined {
const entry = this.keybinds[action];
const entry = this.userKeybinds[action];
if (!entry) return undefined;
const normalizedValue = Array.isArray(entry.value)
? entry.value[0] || ""
: entry.value;
const normalizedValue = entry.value;
if (normalizedValue === "Null") return "";
return normalizedValue || undefined;
}
private getKeyChar(action: string): string {
const entry = this.keybinds[action];
const entry = this.userKeybinds[action];
if (!entry) return "";
return entry.key || "";
}
@@ -257,101 +201,77 @@ export class UserSettingModal extends BaseModal {
}, 5000);
}
toggleDarkMode(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;
toggleDarkMode() {
this.userSettings.toggleDarkMode();
if (typeof enabled !== "boolean") {
console.warn("Unexpected toggle event payload", e);
return;
}
console.log("🌙 Dark Mode:", this.userSettings.darkMode() ? "ON" : "OFF");
}
this.userSettings.set("settings.darkMode", enabled);
private toggleEmojis() {
this.userSettings.toggleEmojis();
if (enabled) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
console.log("🤡 Emojis:", this.userSettings.emojis() ? "ON" : "OFF");
}
this.dispatchEvent(
new CustomEvent("dark-mode-changed", {
detail: { darkMode: enabled },
bubbles: true,
composed: true,
}),
private toggleAlertFrame() {
this.userSettings.toggleAlertFrame();
console.log(
"🚨 Alert frame:",
this.userSettings.alertFrame() ? "ON" : "OFF",
);
console.log("🌙 Dark Mode:", enabled ? "ON" : "OFF");
}
private toggleEmojis(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;
if (typeof enabled !== "boolean") return;
private toggleFxLayer() {
this.userSettings.toggleFxLayer();
this.userSettings.set("settings.emojis", enabled);
console.log("🤡 Emojis:", enabled ? "ON" : "OFF");
console.log(
"💥 Special effects:",
this.userSettings.fxLayer() ? "ON" : "OFF",
);
}
private toggleAlertFrame(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;
if (typeof enabled !== "boolean") return;
private toggleStructureSprites() {
this.userSettings.toggleStructureSprites();
this.userSettings.set("settings.alertFrame", enabled);
console.log("🚨 Alert frame:", enabled ? "ON" : "OFF");
console.log(
"🏠 Structure sprites:",
this.userSettings.structureSprites() ? "ON" : "OFF",
);
}
private toggleFxLayer(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;
if (typeof enabled !== "boolean") return;
private toggleCursorCostLabel() {
this.userSettings.toggleCursorCostLabel();
this.userSettings.set("settings.specialEffects", enabled);
console.log("💥 Special effects:", enabled ? "ON" : "OFF");
console.log(
"💰 Cursor build cost:",
this.userSettings.cursorCostLabel() ? "ON" : "OFF",
);
}
private toggleStructureSprites(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;
if (typeof enabled !== "boolean") return;
private toggleAnonymousNames() {
this.userSettings.toggleRandomName();
this.userSettings.set("settings.structureSprites", enabled);
console.log("🏠 Structure sprites:", enabled ? "ON" : "OFF");
console.log(
"🙈 Anonymous Names:",
this.userSettings.anonymousNames() ? "ON" : "OFF",
);
}
private toggleCursorCostLabel(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;
if (typeof enabled !== "boolean") return;
this.userSettings.set("settings.cursorCostLabel", enabled);
console.log("💰 Cursor build cost:", enabled ? "ON" : "OFF");
private toggleLobbyIdVisibility() {
this.userSettings.toggleLobbyIdVisibility();
console.log(
"👁️ Hidden Lobby IDs:",
!this.userSettings.lobbyIdVisibility() ? "ON" : "OFF",
);
}
private toggleAnonymousNames(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;
if (typeof enabled !== "boolean") return;
this.userSettings.set("settings.anonymousNames", enabled);
console.log("🙈 Anonymous Names:", enabled ? "ON" : "OFF");
}
private toggleLobbyIdVisibility(e: CustomEvent<{ checked: boolean }>) {
const hideIds = e.detail?.checked;
if (typeof hideIds !== "boolean") return;
this.userSettings.set("settings.lobbyIdVisibility", !hideIds); // Invert because checked=hide
console.log("👁️ Hidden Lobby IDs:", hideIds ? "ON" : "OFF");
}
private toggleLeftClickOpensMenu(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;
if (typeof enabled !== "boolean") return;
this.userSettings.set("settings.leftClickOpensMenu", enabled);
console.log("🖱️ Left Click Opens Menu:", enabled ? "ON" : "OFF");
private toggleLeftClickOpensMenu() {
this.userSettings.toggleLeftClickOpenMenu();
console.log(
"🖱️ Left Click Opens Menu:",
this.userSettings.leftClickOpensMenu() ? "ON" : "OFF",
);
this.requestUpdate();
}
@@ -360,7 +280,7 @@ export class UserSettingModal extends BaseModal {
const value = e.detail?.value;
if (typeof value === "number") {
const ratio = value / 100;
localStorage.setItem("settings.attackRatio", ratio.toString());
this.userSettings.setAttackRatio(ratio);
} else {
console.warn("Slider event missing detail.value", e);
}
@@ -385,39 +305,23 @@ export class UserSettingModal extends BaseModal {
console.warn("Select event missing detail.value", e);
return;
}
this.userSettings.setFloat(
"settings.attackRatioIncrement",
Math.round(value),
);
this.userSettings.setAttackRatioIncrement(Math.round(value));
this.requestUpdate();
}
private toggleTerritoryPatterns(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;
if (typeof enabled !== "boolean") return;
private toggleTerritoryPatterns() {
this.userSettings.toggleTerritoryPatterns();
this.userSettings.set("settings.territoryPatterns", enabled);
console.log("🏳️ Territory Patterns:", enabled ? "ON" : "OFF");
console.log(
"🏳️ Territory Patterns:",
this.userSettings.territoryPatterns() ? "ON" : "OFF",
);
}
private togglePerformanceOverlay(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;
if (typeof enabled !== "boolean") return;
this.userSettings.set("settings.performanceOverlay", enabled);
private togglePerformanceOverlay() {
this.userSettings.togglePerformanceOverlay();
}
private openFlagSelector = () => {
const flagInputModal =
document.querySelector<FlagInputModalElement>("#flag-input-modal");
if (flagInputModal?.open) {
this.close();
flagInputModal.returnTo = "#" + (this.id || "page-settings");
flagInputModal.open();
}
};
render() {
const activeContent =
this.activeTab === "basic"
@@ -488,6 +392,26 @@ export class UserSettingModal extends BaseModal {
private renderKeybindSettings() {
return html`
<div
class="flex items-center gap-2 px-3 py-2 mb-3 rounded-lg bg-blue-500/10 border border-blue-500/20 text-blue-300/70 text-xs"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3.5 w-3.5 shrink-0 opacity-70"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
${translateText("user_setting.keybinds_hint")}
</div>
<h2
class="text-blue-200 text-xl font-bold mt-4 mb-3 border-b border-white/10 pb-2"
>
@@ -508,7 +432,7 @@ export class UserSettingModal extends BaseModal {
action="coordinateGrid"
label=${translateText("user_setting.coordinate_grid_label")}
description=${translateText("user_setting.coordinate_grid_desc")}
defaultKey=${DefaultKeybinds.coordinateGrid}
defaultKey=${this.defaultKeybinds.coordinateGrid}
.value=${this.getKeyValue("coordinateGrid")}
.display=${this.getKeyChar("coordinateGrid")}
@change=${this.handleKeybindChange}
@@ -630,7 +554,7 @@ export class UserSettingModal extends BaseModal {
action="modifierKey"
label=${translateText("user_setting.build_menu_modifier")}
description=${translateText("user_setting.build_menu_modifier_desc")}
.defaultKey=${DefaultKeybinds.modifierKey}
.defaultKey=${this.defaultKeybinds.modifierKey}
.value=${this.getKeyValue("modifierKey")}
.display=${this.getKeyChar("modifierKey")}
@change=${this.handleKeybindChange}
@@ -640,7 +564,7 @@ export class UserSettingModal extends BaseModal {
action="altKey"
label=${translateText("user_setting.emoji_menu_modifier")}
description=${translateText("user_setting.emoji_menu_modifier_desc")}
.defaultKey=${DefaultKeybinds.altKey}
.defaultKey=${this.defaultKeybinds.altKey}
.value=${this.getKeyValue("altKey")}
.display=${this.getKeyChar("altKey")}
@change=${this.handleKeybindChange}
@@ -650,7 +574,7 @@ export class UserSettingModal extends BaseModal {
action="pauseGame"
label=${translateText("user_setting.pause_game")}
description=${translateText("user_setting.pause_game_desc")}
.defaultKey=${DefaultKeybinds.pauseGame}
.defaultKey=${this.defaultKeybinds.pauseGame}
.value=${this.getKeyValue("pauseGame")}
.display=${this.getKeyChar("pauseGame")}
@change=${this.handleKeybindChange}
@@ -660,7 +584,7 @@ export class UserSettingModal extends BaseModal {
action="gameSpeedUp"
label=${translateText("user_setting.game_speed_up")}
description=${translateText("user_setting.game_speed_up_desc")}
.defaultKey=${DefaultKeybinds.gameSpeedUp}
.defaultKey=${this.defaultKeybinds.gameSpeedUp}
.value=${this.getKeyValue("gameSpeedUp")}
.display=${this.getKeyChar("gameSpeedUp")}
@change=${this.handleKeybindChange}
@@ -670,7 +594,7 @@ export class UserSettingModal extends BaseModal {
action="gameSpeedDown"
label=${translateText("user_setting.game_speed_down")}
description=${translateText("user_setting.game_speed_down_desc")}
.defaultKey=${DefaultKeybinds.gameSpeedDown}
.defaultKey=${this.defaultKeybinds.gameSpeedDown}
.value=${this.getKeyValue("gameSpeedDown")}
.display=${this.getKeyChar("gameSpeedDown")}
@change=${this.handleKeybindChange}
@@ -736,12 +660,38 @@ export class UserSettingModal extends BaseModal {
action="swapDirection"
label=${translateText("user_setting.swap_direction")}
description=${translateText("user_setting.swap_direction_desc")}
.defaultKey=${DefaultKeybinds.swapDirection}
.defaultKey=${this.defaultKeybinds.swapDirection}
.value=${this.getKeyValue("swapDirection")}
.display=${this.getKeyChar("swapDirection")}
@change=${this.handleKeybindChange}
></setting-keybind>
<h2
class="text-blue-200 text-xl font-bold mt-8 mb-3 border-b border-white/10 pb-2"
>
${translateText("user_setting.ally_keybinds")}
</h2>
<setting-keybind
action="requestAlliance"
label=${translateText("user_setting.request_alliance")}
description=${translateText("user_setting.request_alliance_desc")}
defaultKey="KeyK"
.value=${this.getKeyValue("requestAlliance")}
.display=${this.getKeyChar("requestAlliance")}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="breakAlliance"
label=${translateText("user_setting.break_alliance")}
description=${translateText("user_setting.break_alliance_desc")}
defaultKey="KeyL"
.value=${this.getKeyValue("breakAlliance")}
.display=${this.getKeyChar("breakAlliance")}
@change=${this.handleKeybindChange}
></setting-keybind>
<h2
class="text-blue-200 text-xl font-bold mt-8 mb-3 border-b border-white/10 pb-2"
>
@@ -828,43 +778,13 @@ export class UserSettingModal extends BaseModal {
private renderBasicSettings() {
return html`
<!-- 🚩 Flag Selector -->
<div
class="flex flex-row items-center justify-between w-full p-4 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 transition-all gap-4 cursor-pointer"
role="button"
tabindex="0"
@click=${this.openFlagSelector}
@keydown=${(e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
this.openFlagSelector();
}
}}
>
<div class="flex flex-col flex-1 min-w-0 mr-4">
<div class="text-white font-bold text-base block mb-1">
${translateText("flag_input.title")}
</div>
<div class="text-white/50 text-sm leading-snug">
${translateText("flag_input.button_title")}
</div>
</div>
<div
class="relative inline-block w-12 h-8 shrink-0 rounded overflow-hidden border border-white/20"
>
<flag-input class="w-full h-full pointer-events-none"></flag-input>
</div>
</div>
<!-- 🌙 Dark Mode -->
<setting-toggle
label="${translateText("user_setting.dark_mode_label")}"
description="${translateText("user_setting.dark_mode_desc")}"
id="dark-mode-toggle"
.checked=${this.userSettings.darkMode()}
@change=${(e: CustomEvent<{ checked: boolean }>) =>
this.toggleDarkMode(e)}
@change=${this.toggleDarkMode}
></setting-toggle>
<!-- 😊 Emojis -->
@@ -945,7 +865,7 @@ export class UserSettingModal extends BaseModal {
label="${translateText("user_setting.lobby_id_visibility_label")}"
description="${translateText("user_setting.lobby_id_visibility_desc")}"
id="lobby-id-visibility-toggle"
.checked=${!this.userSettings.get("settings.lobbyIdVisibility", true)}
.checked=${!this.userSettings.lobbyIdVisibility()}
@change=${this.toggleLobbyIdVisibility}
></setting-toggle>
@@ -973,8 +893,7 @@ export class UserSettingModal extends BaseModal {
description="${translateText("user_setting.attack_ratio_desc")}"
min="1"
max="100"
.value=${Number(localStorage.getItem("settings.attackRatio") ?? "0.2") *
100}
.value=${this.userSettings.attackRatio() * 100}
@change=${this.sliderAttackRatio}
></setting-slider>
+35
View File
@@ -186,6 +186,36 @@ export function getActiveModifiers(
formattedValue: translateText("common.disabled"),
});
}
if (modifiers.isPortsDisabled) {
result.push({
labelKey: "public_game_modifier.ports_disabled_label",
badgeKey: "public_game_modifier.ports_disabled",
});
}
if (modifiers.isNukesDisabled) {
result.push({
labelKey: "public_game_modifier.nukes_disabled_label",
badgeKey: "public_game_modifier.nukes_disabled",
});
}
if (modifiers.isSAMsDisabled) {
result.push({
labelKey: "public_game_modifier.sams_disabled_label",
badgeKey: "public_game_modifier.sams_disabled",
});
}
if (modifiers.isPeaceTime) {
result.push({
labelKey: "public_game_modifier.peace_time_label",
badgeKey: "public_game_modifier.peace_time",
});
}
if (modifiers.isWaterNukes) {
result.push({
labelKey: "public_game_modifier.water_nukes_label",
badgeKey: "public_game_modifier.water_nukes",
});
}
return result;
}
@@ -284,6 +314,11 @@ export function formatKeyForDisplay(value: string): string {
// Handle empty string
if (!value) return "";
// Handle Shift+ prefix: format as "Shift+X"
if (value.startsWith("Shift+")) {
return "Shift+" + formatKeyForDisplay(value.slice(6));
}
// Handle space character or "Space" key
if (value === " " || value === "Space") return "Space";
+29
View File
@@ -0,0 +1,29 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { assetUrl } from "../../core/AssetUrls";
@customElement("cap-icon")
export class CapIcon extends LitElement {
@property({ type: Number })
size: number = 48;
createRenderRoot() {
return this;
}
render() {
return html`
<div
class="inline-flex items-center justify-center"
style="width:${this.size}px; height:${this.size}px;"
>
<img
src=${assetUrl("images/BottleCapIcon.svg")}
alt="Caps"
style="width:${this.size}px; height:${this.size}px;"
draggable="false"
/>
</div>
`;
}
}
+3 -6
View File
@@ -1,6 +1,6 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { getServerConfigFromClient } from "../../core/configuration/ConfigLoader";
import { getRuntimeClientServerConfig } from "../../core/configuration/ConfigLoader";
import { UserSettings } from "../../core/game/UserSettings";
import { crazyGamesSDK } from "../CrazyGamesSDK";
import { copyToClipboard, translateText } from "../Utils";
@@ -33,10 +33,7 @@ export class CopyButton extends LitElement {
changedProperties: Map<string | number | symbol, unknown>,
) {
if (changedProperties.has("lobbyId")) {
this.lobbyIdVisible = this.userSettings.get(
"settings.lobbyIdVisibility",
true,
);
this.lobbyIdVisible = this.userSettings.lobbyIdVisibility();
this.copySuccess = false;
}
if (changedProperties.has("copyText")) {
@@ -66,7 +63,7 @@ export class CopyButton extends LitElement {
}
private async buildCopyUrl(): Promise<string> {
const config = await getServerConfigFromClient();
const config = await getRuntimeClientServerConfig();
let url = `${window.location.origin}/${config.workerPath(this.lobbyId)}/game/${this.lobbyId}`;
if (this.includeLobbyQuery) {
url += `?lobby&s=${encodeURIComponent(this.lobbySuffix)}`;
+162
View File
@@ -0,0 +1,162 @@
import { html, LitElement, nothing, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Flag, Pack, Pattern } from "../../core/CosmeticSchemas";
import { PlayerPattern } from "../../core/Schemas";
import {
PaymentMethod,
ResolvedCosmetic,
translateCosmetic,
} from "../Cosmetics";
import { translateText } from "../Utils";
import "./CapIcon";
import "./CosmeticContainer";
import "./CosmeticInfo";
import { renderPatternPreview } from "./PatternPreview";
import "./PlutoniumIcon";
@customElement("cosmetic-button")
export class CosmeticButton extends LitElement {
@property({ type: Object })
resolved!: ResolvedCosmetic;
@property({ type: Boolean })
selected: boolean = false;
@property({ type: Function })
onSelect?: (resolved: ResolvedCosmetic) => void;
@property({ type: Function })
onPurchase?: (resolved: ResolvedCosmetic, method: PaymentMethod) => void;
createRenderRoot() {
return this;
}
private handleClick() {
this.onSelect?.(this.resolved);
}
private get displayName(): string {
const c = this.resolved.cosmetic;
if (c === null) {
return translateText("territory_patterns.pattern.default");
}
if (this.resolved.type === "pattern") {
return translateCosmetic("territory_patterns.pattern", c.name);
}
if (this.resolved.type === "pack") {
return (c as Pack).displayName;
}
return translateCosmetic("flags", c.name);
}
private renderPreview(): TemplateResult {
if (this.resolved.type === "pattern") {
const c = this.resolved.cosmetic;
const playerPattern: PlayerPattern | null =
c === null
? null
: {
name: c.name,
patternData: (c as Pattern).pattern,
colorPalette: this.resolved.colorPalette ?? undefined,
};
return renderPatternPreview(playerPattern, 150, 150);
}
if (this.resolved.type === "pack") {
const pack = this.resolved.cosmetic as Pack;
const isHard = pack.currency === "hard";
const icon = isHard
? html`<plutonium-icon
class="flex-1 flex items-center"
.size=${100}
></plutonium-icon>`
: html`<cap-icon
class="flex-1 flex items-center"
.size=${100}
></cap-icon>`;
const colorClass = isHard ? "text-green-400" : "text-amber-700";
const currencyKey = isHard ? "cosmetics.hard" : "cosmetics.soft";
return html`<div
class="flex flex-col items-center justify-end h-full w-full text-center gap-1 pb-1"
>
${icon}
<span class="text-lg font-black ${colorClass}"
>${pack.amount.toLocaleString()}</span
>
<span class="text-[10px] font-bold text-white/50 uppercase"
>${translateText(currencyKey)}</span
>
</div>`;
}
const c = this.resolved.cosmetic as Flag;
return html`<img
src=${c.url}
alt=${c.name}
class="w-full h-full object-contain pointer-events-none"
draggable="false"
loading="lazy"
@error=${(e: Event) => {
const img = e.currentTarget as HTMLImageElement;
const fallback = "/flags/xx.svg";
if (img.src && !img.src.endsWith(fallback)) {
img.src = fallback;
}
}}
/>`;
}
render() {
const c = this.resolved.cosmetic;
const isPurchasable = this.resolved.relationship === "purchasable";
const type = this.resolved.type;
const isPattern = type === "pattern";
const sizeClass = type === "flag" ? "gap-1 p-1.5 w-36" : "gap-2 p-3 w-48";
const crazygamesClass = isPattern ? "no-crazygames " : "";
return html`
<cosmetic-container
class="${crazygamesClass}flex flex-col items-center justify-between ${sizeClass} h-full"
.rarity=${c?.rarity ?? "common"}
.selected=${this.selected}
.product=${isPurchasable && c?.product ? c.product : null}
.priceHard=${isPurchasable ? (c?.priceHard ?? null) : null}
.priceSoft=${isPurchasable ? (c?.priceSoft ?? null) : null}
.onPurchaseDollar=${isPurchasable && c?.product
? () => this.onPurchase?.(this.resolved, "dollar")
: undefined}
.onPurchaseHard=${isPurchasable && c?.priceHard !== undefined
? () => this.onPurchase?.(this.resolved, "hard")
: undefined}
.onPurchaseSoft=${isPurchasable && c?.priceSoft !== undefined
? () => this.onPurchase?.(this.resolved, "soft")
: undefined}
.name=${this.displayName}
>
<button
class="group relative flex flex-col items-center w-full ${isPattern
? "gap-2"
: "gap-1"} rounded-lg cursor-pointer transition-all duration-200 flex-1"
@click=${() => this.handleClick()}
>
${(c?.product ?? c?.priceHard ?? c?.priceSoft)
? html`<cosmetic-info
.artist=${c.artist}
.rarity=${c.rarity}
.colorPalette=${this.resolved.colorPalette?.name}
.showAdFree=${isPurchasable}
></cosmetic-info>`
: nothing}
<div
class="w-full aspect-square flex items-center justify-center bg-white/5 rounded-lg p-2 border border-white/10 group-hover:border-white/20 transition-colors duration-200 overflow-hidden"
>
${this.renderPreview()}
</div>
</button>
</cosmetic-container>
`;
}
}
+458
View File
@@ -0,0 +1,458 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Product } from "../../core/CosmeticSchemas";
import "./PurchaseButton";
type Rarity = "common" | "uncommon" | "rare" | "epic" | "legendary" | string;
interface RarityConfig {
gradient: string;
border: string;
glow: string;
hoverGlowSize: string;
nameColor: string;
legendary?: boolean;
shimmer?: boolean;
shimmerColor?: string; // rgb triplet e.g. "255,200,80"
borderSweep?: boolean;
borderSweepColor?: string; // rgb triplet e.g. "192,132,252"
}
const rarityConfig: Record<string, RarityConfig> = {
common: {
gradient: "rgba(80,80,80,0.55)",
border: "rgba(255,255,255,0.15)",
glow: "rgba(255,255,255,0.5)",
hoverGlowSize: "10px",
nameColor: "rgba(255,255,255,0.7)",
},
uncommon: {
gradient: "rgba(30,100,30,0.65)",
border: "rgba(74,222,128,0.45)",
glow: "rgba(74,222,128,0.6)",
hoverGlowSize: "12px",
nameColor: "rgba(255,255,255,1)",
},
rare: {
gradient: "rgba(20,60,160,0.70)",
border: "rgba(96,165,250,0.50)",
glow: "rgba(96,165,250,0.7)",
hoverGlowSize: "14px",
nameColor: "rgba(255,255,255,1)",
},
epic: {
gradient: "rgba(90,20,160,0.75)",
border: "rgba(192,132,252,0.60)",
glow: "rgba(192,132,252,0.85)",
hoverGlowSize: "14px",
nameColor: "rgba(255,255,255,1)",
shimmer: true,
shimmerColor: "192,132,252",
},
legendary: {
gradient: "rgba(180,80,0,0.75)",
border: "rgba(251,146,60,0.65)",
glow: "rgba(251,146,60,0.95)",
hoverGlowSize: "25px",
nameColor: "rgba(255,255,255,1)",
legendary: true,
shimmer: true,
shimmerColor: "255,200,80",
borderSweep: true,
borderSweepColor: "255,200,80",
},
};
const fallback = rarityConfig["common"];
const STYLE_ID = "cosmetic-container-styles";
if (!document.getElementById(STYLE_ID)) {
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
@keyframes legendary-pulse {
0% { box-shadow: 0 0 15px rgba(251,146,60,0.8), 0 0 30px rgba(251,146,60,0.4); }
50% { box-shadow: 0 0 25px rgba(251,146,60,0.9), 0 0 45px rgba(251,146,60,0.5); }
100% { box-shadow: 0 0 15px rgba(251,146,60,0.8), 0 0 30px rgba(251,146,60,0.4); }
}
@keyframes legendary-shimmer {
0% { left: -60%; }
100% { left: 160%; }
}
@keyframes legendary-border-sweep {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes sparkle-twinkle-0 {
0%, 100% { opacity: 0; transform: scale(0.5) rotate(0deg); }
40%, 60% { opacity: 1; transform: scale(1.2) rotate(20deg); }
}
@keyframes sparkle-twinkle-1 {
0%, 100% { opacity: 0; transform: scale(0.5) rotate(0deg); }
30%, 55% { opacity: 1; transform: scale(1.1) rotate(-15deg); }
}
@keyframes sparkle-twinkle-2 {
0%, 100% { opacity: 0; transform: scale(0.5) rotate(0deg); }
45%, 65% { opacity: 1; transform: scale(1.3) rotate(10deg); }
}
@keyframes sparkle-twinkle-3 {
0%, 100% { opacity: 0; transform: scale(0.5) rotate(0deg); }
35%, 58% { opacity: 1; transform: scale(1.0) rotate(-20deg); }
}
.legendary-hovered {
animation: legendary-pulse 1.4s ease-in-out infinite;
}
.legendary-shimmer.active {
animation: legendary-shimmer 0.8s ease-in-out;
}
.legendary-border-sweep {
animation: legendary-border-sweep 8s linear infinite;
}
.legendary-sparkle-0 { animation: sparkle-twinkle-0 1.6s ease-in-out infinite; }
.legendary-sparkle-1 { animation: sparkle-twinkle-1 1.9s ease-in-out infinite 0.3s; }
.legendary-sparkle-2 { animation: sparkle-twinkle-2 1.7s ease-in-out infinite 0.7s; }
.legendary-sparkle-3 { animation: sparkle-twinkle-3 2.0s ease-in-out infinite 0.1s; }
@keyframes cosmetic-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.cosmetic-loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.6);
border-radius: 0.75rem;
z-index: 20;
}
.cosmetic-loading-spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(255,255,255,0.2);
border-top-color: rgb(74,222,128);
border-radius: 50%;
animation: cosmetic-spin 0.8s linear infinite;
}
`;
document.head.appendChild(style);
}
@customElement("cosmetic-container")
export class CosmeticContainer extends LitElement {
@property({ type: String })
rarity: Rarity = "common";
@property({ type: Boolean })
selected: boolean = false;
@property({ type: String })
name: string = "";
@property({ type: Object })
product: Product | null = null;
@property({ type: Number })
priceHard: number | null = null;
@property({ type: Number })
priceSoft: number | null = null;
@property({ type: Function })
onPurchaseDollar?: () => void;
@property({ type: Function })
onPurchaseHard?: () => void;
@property({ type: Function })
onPurchaseSoft?: () => void;
private static _backdrop: HTMLDivElement | null = null;
private static _ensureBackdrop(): HTMLDivElement {
if (!CosmeticContainer._backdrop) {
const el = document.createElement("div");
el.style.cssText = `
pointer-events: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0);
z-index: 9;
transition: background 0.3s ease;
`;
document.body.appendChild(el);
CosmeticContainer._backdrop = el;
}
return CosmeticContainer._backdrop;
}
private _shimmer: HTMLDivElement | null = null;
private _borderSweep: HTMLDivElement | null = null;
private _sparkles: HTMLDivElement[] = [];
private _glowColor = fallback.glow;
private _glowSize = fallback.hoverGlowSize;
private _isLegendary = false;
private _hasGlint = false;
private _hasBorderSweep = false;
private _loading = false;
private _loadingOverlay: HTMLDivElement | null = null;
createRenderRoot() {
return this;
}
private applyHostStyles() {
const cfg = rarityConfig[this.rarity] ?? fallback;
this._glowColor = cfg.glow;
this._glowSize = cfg.hoverGlowSize;
this._isLegendary = !!cfg.legendary;
this._hasGlint = !!cfg.shimmer;
this._hasBorderSweep = !!cfg.borderSweep;
this.style.position = "relative";
this.style.overflow = "hidden";
this.style.background = `linear-gradient(to top, ${cfg.gradient} 0%, rgba(15,15,20,0.85) 100%)`;
this.style.border = `1px solid ${this.selected ? cfg.glow : cfg.border}`;
this.style.backdropFilter = "blur(8px)";
this.style.borderRadius = "0.75rem";
this.style.transition =
"border-color 0.2s, background 0.2s, transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.2s";
this.style.zIndex = "0";
const hasPurchase =
this.product !== null ||
this.priceHard !== null ||
this.priceSoft !== null;
this.style.cursor = hasPurchase ? "pointer" : "";
if (this.selected) {
this.style.boxShadow = `0 0 18px ${cfg.glow}`;
} else if (!this.classList.contains("legendary-hovered")) {
this.style.boxShadow = "";
}
}
private _ensureLegendaryElements() {
if (this._shimmer || this._borderSweep) return;
// Shimmer sweep — epic and legendary
if (this._hasGlint) {
const shimmer = document.createElement("div");
shimmer.className = "legendary-shimmer";
shimmer.style.cssText = `
pointer-events: none;
position: absolute;
top: 0;
left: -60%;
width: 40%;
height: 100%;
background: linear-gradient(90deg, transparent 0%, rgba(${(rarityConfig[this.rarity] ?? fallback).shimmerColor ?? "255,200,80"},0.45) 50%, transparent 100%);
transform: skewX(-15deg);
z-index: 10;
display: none;
`;
this.appendChild(shimmer);
this._shimmer = shimmer;
}
if (!this._hasBorderSweep) return;
const sweepWrap = document.createElement("div");
sweepWrap.style.cssText = `
pointer-events: none;
position: absolute;
inset: -2px;
border-radius: 0.85rem;
z-index: -1;
overflow: hidden;
display: none;
`;
const sweepInner = document.createElement("div");
sweepInner.className = "legendary-border-sweep";
const sc =
(rarityConfig[this.rarity] ?? fallback).borderSweepColor ?? "255,200,80";
sweepInner.style.cssText = `
position: absolute;
inset: -100%;
background: conic-gradient(
from 0deg,
transparent 0deg,
rgba(${sc},0.0) 60deg,
rgba(${sc},0.9) 120deg,
rgba(${sc},1) 180deg,
rgba(${sc},0.9) 240deg,
rgba(${sc},0.0) 300deg,
transparent 360deg
);
`;
// Inner mask to hide center, show only border ring
const sweepMask = document.createElement("div");
sweepMask.style.cssText = `
position: absolute;
inset: 2px;
border-radius: 0.75rem;
background: transparent;
`;
sweepWrap.appendChild(sweepInner);
sweepWrap.appendChild(sweepMask);
this.appendChild(sweepWrap);
this._borderSweep = sweepWrap;
// Corner sparkles ✦
const corners = [
{ top: "4px", left: "4px" },
{ top: "4px", right: "4px" },
{ bottom: "4px", left: "4px" },
{ bottom: "4px", right: "4px" },
];
this._sparkles = corners.map((pos, i) => {
const el = document.createElement("div");
el.className = `legendary-sparkle-${i}`;
el.textContent = "✦";
el.style.cssText = `
pointer-events: none;
position: absolute;
font-size: 10px;
color: rgba(255,220,100,0.9);
text-shadow: 0 0 6px rgba(255,200,60,1);
z-index: 11;
opacity: 0;
display: none;
line-height: 1;
`;
Object.assign(el.style, pos);
this.appendChild(el);
return el;
});
}
private _onClick = () => {
if (CosmeticContainer._backdrop) {
CosmeticContainer._backdrop.style.background = "rgba(0,0,0,0)";
}
// Only auto-fire container click when there's exactly one purchase path
const handlers = [
this.onPurchaseDollar,
this.onPurchaseHard,
this.onPurchaseSoft,
].filter(Boolean);
if (handlers.length === 1 && !this._loading) {
this._loading = true;
this._showLoadingOverlay();
Promise.resolve(handlers[0]!()).finally(() => {
this._hideLoadingOverlay();
});
}
};
private _showLoadingOverlay() {
if (this._loadingOverlay) return;
const overlay = document.createElement("div");
overlay.className = "cosmetic-loading-overlay";
overlay.innerHTML = `<div class="cosmetic-loading-spinner"></div>`;
this.appendChild(overlay);
this._loadingOverlay = overlay;
}
private _hideLoadingOverlay() {
this._loadingOverlay?.remove();
this._loadingOverlay = null;
this._loading = false;
}
private _onMouseEnter = () => {
if (this._hasGlint || this._hasBorderSweep) {
this._ensureLegendaryElements();
}
if (this._isLegendary) {
this.style.transform = "scale(1.12)";
this.style.zIndex = "10";
this.classList.add("legendary-hovered");
this._sparkles.forEach((s) => (s.style.display = "block"));
CosmeticContainer._ensureBackdrop().style.background = "rgba(0,0,0,0.6)";
}
if (this._hasBorderSweep && this._borderSweep) {
this._borderSweep.style.display = "block";
}
if (this._hasGlint && this._shimmer) {
this._shimmer.style.display = "block";
this._shimmer.classList.remove("active");
void this._shimmer.offsetWidth;
this._shimmer.classList.add("active");
}
if (!this._isLegendary && !this.selected) {
this.style.boxShadow = `0 0 ${this._glowSize} ${this._glowColor}`;
}
};
private _onMouseLeave = () => {
if (this._isLegendary) {
this.style.transform = "";
this.style.zIndex = "0";
this.classList.remove("legendary-hovered");
this._sparkles.forEach((s) => (s.style.display = "none"));
if (CosmeticContainer._backdrop) {
CosmeticContainer._backdrop.style.background = "rgba(0,0,0,0)";
}
}
if (this._hasGlint && this._shimmer) this._shimmer.style.display = "none";
if (this._hasBorderSweep && this._borderSweep)
this._borderSweep.style.display = "none";
if (!this.selected) this.style.boxShadow = "";
};
private _nameEl: HTMLDivElement | null = null;
private _updateNameEl() {
if (this.name) {
this._nameEl ??= document.createElement("div");
const cfg = rarityConfig[this.rarity] ?? fallback;
this._nameEl.className = `text-xs font-bold uppercase tracking-wider text-center truncate w-full`;
this._nameEl.style.color = cfg.nameColor;
this._nameEl.title = this.name;
this._nameEl.textContent = this.name;
// Always ensure it's the first child
if (this.firstChild !== this._nameEl) {
this.prepend(this._nameEl);
}
} else if (this._nameEl) {
this._nameEl.remove();
this._nameEl = null;
}
}
connectedCallback() {
super.connectedCallback();
this.applyHostStyles();
this._updateNameEl();
this.addEventListener("mouseenter", this._onMouseEnter);
this.addEventListener("mouseleave", this._onMouseLeave);
this.addEventListener("click", this._onClick);
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("mouseenter", this._onMouseEnter);
this.removeEventListener("mouseleave", this._onMouseLeave);
this.removeEventListener("click", this._onClick);
}
updated() {
this.applyHostStyles();
this._updateNameEl();
}
render() {
return html`
<slot></slot>
${this.product || this.priceHard !== null || this.priceSoft !== null
? html`<purchase-button
.product=${this.product}
.priceHard=${this.priceHard}
.priceSoft=${this.priceSoft}
.rarity=${this.rarity}
.onPurchaseDollar=${this.onPurchaseDollar}
.onPurchaseHard=${this.onPurchaseHard}
.onPurchaseSoft=${this.onPurchaseSoft}
></purchase-button>`
: null}
`;
}
}
+82
View File
@@ -0,0 +1,82 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { translateCosmetic } from "../Cosmetics";
import { translateText } from "../Utils";
const rarityColors: Record<string, string> = {
common: "text-white/60",
uncommon: "text-green-400",
rare: "text-blue-400",
epic: "text-purple-300",
legendary: "text-orange-400",
};
@customElement("cosmetic-info")
export class CosmeticInfo extends LitElement {
@property({ type: String })
artist?: string;
@property({ type: String })
rarity?: string;
@property({ type: String })
colorPalette?: string;
@property({ type: Boolean })
showAdFree: boolean = false;
createRenderRoot() {
return this;
}
render() {
if (!this.artist && !this.rarity && !this.colorPalette) {
return nothing;
}
const rarityColor = rarityColors[this.rarity ?? ""] ?? "text-white/70";
return html`
<div
class="absolute -top-1 -right-1 z-10 group/artist"
@click=${(e: Event) => e.stopPropagation()}
>
<div
class="w-6 h-6 rounded-full bg-white/20 hover:bg-white/40 flex items-center justify-center cursor-help transition-colors duration-150"
>
<span class="text-xs font-bold text-white/70">?</span>
</div>
<div
class="hidden group-hover/artist:block absolute top-7 right-0 bg-zinc-800 text-white text-xs px-2.5 py-1.5 rounded shadow-lg whitespace-nowrap z-20 border border-white/10 flex flex-col gap-0.5"
>
${this.rarity
? html`<div
class="font-bold uppercase tracking-wider ${rarityColor}"
>
${translateText(`cosmetics.${this.rarity}`) || this.rarity}
</div>`
: nothing}
${this.showAdFree
? html`<div class="text-green-400 font-bold">
${translateText("cosmetics.adfree")}
</div>`
: nothing}
${this.colorPalette
? html`<div>
${translateText("cosmetics.color_label")}
${translateCosmetic(
"territory_patterns.color_palette",
this.colorPalette,
)}
</div>`
: nothing}
${this.artist
? html`<div>
${translateText("cosmetics.artist_label")} ${this.artist}
</div>`
: nothing}
</div>
</div>
`;
}
}
+43
View File
@@ -0,0 +1,43 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { translateText } from "../Utils";
import "./CapIcon";
import "./PlutoniumIcon";
@customElement("currency-display")
export class CurrencyDisplay extends LitElement {
@property({ type: Number })
hard: number = 0;
@property({ type: Number })
soft: number = 0;
createRenderRoot() {
return this;
}
render() {
return html`
<div class="flex gap-3 justify-center">
<div
class="flex items-center gap-1.5"
title=${translateText("cosmetics.hard")}
>
<plutonium-icon .size=${16}></plutonium-icon>
<span class="text-sm font-bold text-green-400"
>${this.hard.toLocaleString()}</span
>
</div>
<div
class="flex items-center gap-1.5"
title=${translateText("cosmetics.soft")}
>
<cap-icon .size=${20} style="margin-top:3px"></cap-icon>
<span class="text-sm font-bold text-amber-700"
>${this.soft.toLocaleString()}</span
>
</div>
</div>
`;
}
}
+12 -46
View File
@@ -1,5 +1,6 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { assetUrl } from "../../core/AssetUrls";
import { NavNotificationsController } from "./NavNotificationsController";
@customElement("desktop-nav-bar")
@@ -52,47 +53,12 @@ export class DesktopNavBar extends LitElement {
class="hidden lg:flex w-full bg-zinc-900/90 backdrop-blur-md items-center justify-center gap-8 py-4 shrink-0 z-50 relative"
>
<div class="flex flex-col items-center justify-center">
<div class="h-8 text-[#2563eb]">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1364 259"
fill="currentColor"
<div class="h-8 text-[#0073b7]">
<img
src=${assetUrl("images/OpenFrontLogo.svg")}
alt="OpenFront"
class="h-full w-auto"
>
<path
d="M0,174V51h15.24v-17.14h16.81v-16.98h16.96V0h1266v17.23h17.13v16.81h16.98v16.96h14.88v123h-15.13v17.08h-17.08v17.08h-16.9v17.04H324.9v16.86h-16.9v16.95h-102v-17.12h-17.07v-17.05H48.73v-17.05h-16.89v-16.89H14.94v-16.89H0ZM1297.95,17.35H65.9v16.7h-17.08v17.08h-14.5v123.08h14.85v16.9h17.08v17.08h139.9v17.08h17.08v16.36h67.9v-16.72h17.08v-17.07h989.88v-17.07h17.08v-16.9h14.44V50.8h-14.75v-17.08h-16.9v-16.37Z"
/>
<path
d="M189.1,154.78v17.07h-16.9v16.75h-51.07v-16.42h-16.9v-17.07h-16.97v-84.88h16.63v-17.07h16.9v-16.84h51.07v16.5h17.07v17.07h16.7v84.89h-16.54ZM137.87,53.1v17.15h-16.6v84.86h16.97v16.61h16.89v-16.97h16.6v-84.86h-16.97v-16.79h-16.89Z"
/>
<path
d="M273.91,104.06v-16.73h50.92v16.45h16.85v68.05h-16.44v17.06h-50.96v16.88h16.4v16.96h-67.28v-16.61h16.33v-101.86h-16.38v-16.98h33.4v16.63c6.12,0,11.72,0,17.31,0,0,22.56,0,45.13,0,67.75h33.59v-67.61h-33.73Z"
/>
<path
d="M631.12,188.64v-16.36h16.53V53.2h-16.25v-16.86h118.33v33.29h-16.65v-16.36h-50.72v50.44h33.36v-16.35h16.99v50.25h-16.6v-16.33h-33.73v50.65h16.37v16.72h-67.63Z"
/>
<path
d="M596.78,103.8v84.94h-33.54v-84.39h-34.03v84.25h-33.85v-101.29h84.5v16.49h16.93Z"
/>
<path
d="M1107.12,188.71v-84.34h-34.03v84.37h-33.7v-101.41h84.42v16.41h16.86v84.96h-33.54Z"
/>
<path
d="M988.1,171.78v16.87h-67.88v-16.38h-16.87v-68.06h16.38v-16.87h68.06v16.38h16.87v68.06h-16.55ZM970.78,104.35h-33.39v67.38h33.39v-67.38Z"
/>
<path
d="M460.77,155.38v16.49h-16.58v16.83h-68.05v-16.5h-16.83v-68.05h16.49v-16.83h68.05v16.49h16.83v34.06h-67.31v33.82h33.47v-16.31h33.92ZM393.39,104.18v16.56h33.3v-16.56h-33.3Z"
/>
<path
d="M1209.13,172h-16.9v-67.9h-16.96v-16.9h16.68v-17.08h16.9v-16.82h16.9v33.58h50.98v16.91h-50.4v67.96h16.48v-16.43h50.95v16.54h-16.55v16.76h-68.08v-16.6Z"
/>
<path
d="M834.91,120.94v16.96h-16.65v33.88h16.41v16.96h-67.29v-16.63h16.34v-67.87h-16.4v-16.97h50.42v33.81h17.3l-.14-.14Z"
/>
<path
d="M835.05,121.08v-33.75h33.76v16.43h16.85v33.96h-33.43v-16.79c-6.13,0-11.73,0-17.32,0,0,0,.14.14.14.14Z"
/>
</svg>
/>
</div>
<div
id="game-version"
@@ -102,7 +68,7 @@ export class DesktopNavBar extends LitElement {
<button
class="nav-menu-item ${currentPage === "page-play"
? "active"
: ""} text-white/70 hover:text-blue-500 font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
: ""} text-white/70 hover:text-[#0073b7] font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-[#0073b7] "
data-page="page-play"
data-i18n="main.play"
></button>
@@ -111,7 +77,7 @@ export class DesktopNavBar extends LitElement {
<button
class="nav-menu-item ${currentPage === "page-news"
? "active"
: ""} text-white/70 hover:text-blue-500 font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
: ""} text-white/70 hover:text-[#0073b7] font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-[#0073b7] "
data-page="page-news"
data-i18n="main.news"
@click=${this._notifications.onNewsClick}
@@ -131,7 +97,7 @@ export class DesktopNavBar extends LitElement {
<button
class="nav-menu-item ${currentPage === "page-item-store"
? "active"
: ""} text-white/70 hover:text-blue-500 font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
: ""} text-white/70 hover:text-[#0073b7] font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-[#0073b7] "
data-page="page-item-store"
data-i18n="main.store"
@click=${this._notifications.onStoreClick}
@@ -148,18 +114,18 @@ export class DesktopNavBar extends LitElement {
: ""}
</div>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
class="nav-menu-item text-white/70 hover:text-[#0073b7] font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-[#0073b7] "
data-page="page-settings"
data-i18n="main.settings"
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
class="nav-menu-item text-white/70 hover:text-[#0073b7] font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-[#0073b7] "
data-page="page-leaderboard"
data-i18n="main.leaderboard"
></button>
<div class="relative">
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
class="nav-menu-item text-white/70 hover:text-[#0073b7] font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-[#0073b7] "
data-page="page-help"
data-i18n="main.help"
@click=${this._notifications.onHelpClick}
@@ -122,6 +122,12 @@ const OPTIONS_ICON = svg`<path
clip-rule="evenodd"
/>`;
const HOST_CHEATS_ICON = svg`<path
fill-rule="evenodd"
d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z"
clip-rule="evenodd"
/>`;
const ENABLES_ICON = svg`<path
fill-rule="evenodd"
d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25zm0 8.625a1.125 1.125 0 100 2.25 1.125 1.125 0 000-2.25zM15.375 12a1.125 1.125 0 112.25 0 1.125 1.125 0 01-2.25 0zM7.5 10.875a1.125 1.125 0 100 2.25 1.125 1.125 0 000-2.25z"
@@ -196,6 +202,12 @@ export interface GameConfigSettingsData {
toggles: ToggleOptionConfig[];
inputCards: TemplateResult[];
};
hostCheats?: {
titleKey: string;
visible: boolean;
toggles: ToggleOptionConfig[];
inputCards: TemplateResult[];
};
unitTypes: {
titleKey: string;
disabledUnits: UnitType[];
@@ -258,6 +270,13 @@ export class GameConfigSettings extends LitElement {
this.emit("nations-changed", customEvent.detail);
};
private handleHostCheatToggle = (toggle: ToggleOptionConfig) => {
this.emit("host-cheat-toggle-changed", {
labelKey: toggle.labelKey,
checked: !toggle.checked,
});
};
private handleUnitToggle = (unit: UnitType, checked: boolean) => {
this.emit("unit-toggle-changed", { unit, checked });
};
@@ -462,6 +481,27 @@ export class GameConfigSettings extends LitElement {
</div>
`,
)}
${settings.hostCheats?.visible
? renderSection(
HOST_CHEATS_ICON,
"text-yellow-400",
"bg-yellow-500/20",
settings.hostCheats.titleKey,
html`
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
${settings.hostCheats.toggles.map((toggle) =>
renderTextCardButton(
translateText(toggle.labelKey),
toggle.checked,
() => this.handleHostCheatToggle(toggle),
"p-4 text-center",
),
)}
${settings.hostCheats.inputCards}
</div>
`,
)
: nothing}
${renderSection(
ENABLES_ICON,
"text-teal-400",
-86
View File
@@ -1,86 +0,0 @@
import { LitElement, html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
export const FOOTER_AD_MIN_HEIGHT = 880;
const FOOTER_AD_TYPE = "standard_iab_head2";
const FOOTER_AD_CONTAINER_ID = "home-footer-ad-container";
@customElement("home-footer-ad")
export class HomeFooterAd extends LitElement {
@state() private shouldShow: boolean = false;
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
this.style.display = "contents";
document.addEventListener("userMeResponse", this.onUserMeResponse);
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener("userMeResponse", this.onUserMeResponse);
this.destroyAd();
}
private onUserMeResponse = () => {
const isDesktop = window.innerWidth >= 640;
if (
!window.adsEnabled ||
(isDesktop && window.innerHeight < FOOTER_AD_MIN_HEIGHT)
) {
return;
}
this.shouldShow = true;
this.updateComplete.then(() => {
this.loadAd();
});
};
private loadAd(): void {
if (!window.ramp) {
console.warn("Playwire RAMP not available for footer ad");
return;
}
try {
window.ramp.que.push(() => {
try {
window.ramp.spaAddAds([
{ type: FOOTER_AD_TYPE, selectorId: FOOTER_AD_CONTAINER_ID },
]);
console.log("Footer ad loaded:", FOOTER_AD_TYPE);
} catch (e) {
console.error("Failed to add footer ad:", e);
}
});
} catch (error) {
console.error("Failed to load footer ad:", error);
}
}
private destroyAd(): void {
try {
window.ramp.destroyUnits(FOOTER_AD_TYPE);
console.log("successfully destroyed footer ad");
} catch (e) {
console.error("error destroying footer ad", e);
}
}
render() {
if (!this.shouldShow) {
return nothing;
}
return html`
<div
id="${FOOTER_AD_CONTAINER_ID}"
class="flex justify-center items-center w-full pointer-events-auto [&_*]:!m-0 [&_*]:!p-0"
style="margin: 0; padding: 0; line-height: 0;"
></div>
`;
}
}
@@ -0,0 +1,195 @@
import { LitElement, html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import { Platform } from "../Platform";
import { translateText } from "../Utils";
const DISMISSED_KEY = "ios_a2hs_banner_dismissed";
const LATER_KEY = "ios_a2hs_banner_later";
@customElement("ios-add-to-home-screen-banner")
export class IOSAddToHomeScreenBanner extends LitElement {
@state() private dismissed = false;
@state() private later = false;
@state() private showGuide = false;
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
try {
this.dismissed = localStorage.getItem(DISMISSED_KEY) === "true";
} catch {
this.dismissed = false;
}
try {
this.later = sessionStorage.getItem(LATER_KEY) === "true";
} catch {
this.later = false;
}
}
private never() {
try {
localStorage.setItem(DISMISSED_KEY, "true");
} catch {
// localStorage unavailable — dismiss for session only
}
this.dismissed = true;
}
private later_() {
try {
sessionStorage.setItem(LATER_KEY, "true");
} catch {
// ignore — this.later still set in memory
}
this.later = true;
}
private openGuide() {
this.showGuide = true;
}
private closeGuide() {
this.showGuide = false;
}
private renderGuideModal() {
if (!this.showGuide) return nothing;
return html`
<div
class="fixed inset-0 bg-black/70 backdrop-blur-sm z-[9999] flex items-end sm:items-center justify-center p-4"
@click=${(e: Event) => {
if (e.target === e.currentTarget) this.closeGuide();
}}
>
<div class="relative w-full max-w-sm">
<div
class="bg-slate-800 border border-slate-600 rounded-2xl w-full p-5 pb-6 flex flex-col gap-4"
role="dialog"
aria-modal="true"
aria-labelledby="ios-banner-modal-title"
>
<div class="flex items-center justify-between">
<h2
id="ios-banner-modal-title"
class="text-white font-bold text-lg"
>
${translateText("ios_banner.modal_title")}
</h2>
<button
class="text-slate-400 hover:text-white text-2xl leading-none"
@click=${this.closeGuide}
aria-label=${translateText("common.close")}
>
×
</button>
</div>
<p class="text-slate-300 text-sm">
${translateText("ios_banner.modal_desc")}
</p>
<ol class="flex flex-col gap-3 text-sm text-slate-200">
<li class="flex items-start gap-3">
<span
class="shrink-0 w-6 h-6 rounded-full bg-[#0073b7] flex items-center justify-center text-white font-bold text-xs"
>1</span
>
<span>${translateText("ios_banner.step_share")}</span>
</li>
<li class="flex items-start gap-3">
<span
class="shrink-0 w-6 h-6 rounded-full bg-[#0073b7] flex items-center justify-center text-white font-bold text-xs"
>2</span
>
<span
>${translateText("ios_banner.step_scroll_and_tap")}
<strong class="text-white"
>${translateText(
"ios_banner.step_add_to_home_label",
)}</strong
></span
>
</li>
<li class="flex items-start gap-3">
<span
class="shrink-0 w-6 h-6 rounded-full bg-[#0073b7] flex items-center justify-center text-white font-bold text-xs"
>3</span
>
<span>${translateText("ios_banner.step_open")}</span>
</li>
</ol>
<button
class="w-full py-2.5 rounded-lg bg-[#0073b7] hover:bg-sky-500 active:bg-sky-700 text-white font-semibold transition-colors"
@click=${this.closeGuide}
>
${translateText("ios_banner.got_it")}
</button>
</div>
</div>
</div>
`;
}
render() {
if (!Platform.isIOS) return nothing;
if (this.dismissed || this.later) return nothing;
if (
(navigator as any).standalone === true ||
window.matchMedia("(display-mode: standalone)").matches
) {
return nothing;
}
return html`
${this.renderGuideModal()}
<div
class="flex flex-col gap-3 w-full px-3 py-3 rounded-xl bg-slate-800/90 border border-slate-600 text-sm text-slate-200"
>
<div class="flex gap-3 items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="shrink-0 w-8 h-8 text-[#0073b7]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
<line x1="12" y1="18" x2="12.01" y2="18" />
</svg>
<span>${translateText("ios_banner.text")}</span>
</div>
<div class="flex flex-col gap-1.5">
<button
class="w-full py-1.5 rounded-lg bg-[#0073b7] hover:bg-sky-500 active:bg-sky-700 text-white font-semibold text-sm transition-colors"
@click=${this.openGuide}
>
${translateText("ios_banner.how")}
</button>
<button
class="w-full py-1.5 rounded-lg bg-slate-700 hover:bg-slate-600 active:bg-slate-800 text-slate-300 text-sm transition-colors"
@click=${this.later_}
>
${translateText("ios_banner.later")}
</button>
<button
class="w-full py-1.5 rounded-lg text-slate-500 hover:text-slate-400 text-xs transition-colors"
@click=${this.never}
>
${translateText("ios_banner.never")}
</button>
</div>
</div>
`;
}
}
+2 -2
View File
@@ -126,7 +126,7 @@ export class LobbyTeamView extends LitElement {
return html`<div
class="px-2 py-1 rounded-sm mb-1 text-xs text-white border
${this.isCurrentPlayer(client)
? "bg-sky-600/20 border-sky-500/40"
? "bg-[#0073b7]/20 border-sky-500/40"
: "bg-gray-700/70 border-transparent"}"
>
${displayName}
@@ -242,7 +242,7 @@ export class LobbyTeamView extends LitElement {
return html` <div
class="px-2 py-1 rounded-sm text-xs flex items-center justify-between border
${this.isCurrentPlayer(p)
? "bg-sky-600/20 border-sky-500/40"
? "bg-[#0073b7]/20 border-sky-500/40"
: "bg-gray-700/70 border-transparent"}"
>
<span class="truncate text-white">${displayName}</span>
+7 -44
View File
@@ -1,5 +1,6 @@
import { html, LitElement, TemplateResult } from "lit";
import { customElement } from "lit/decorators.js";
import { assetUrl } from "../../core/AssetUrls";
import { NavNotificationsController } from "./NavNotificationsController";
@customElement("mobile-nav-bar")
@@ -72,52 +73,14 @@ export class MobileNavBar extends LitElement {
>
<!-- Logo + Menu -->
<div
class="flex flex-col text-[#2563eb] mb-[clamp(1rem,2vh,2rem)] ml-[clamp(0.2rem,0.4vw,0.4vh)]"
class="flex flex-col text-[#0073b7] mb-[clamp(1rem,2vh,2rem)] ml-[clamp(0.2rem,0.4vw,0.4vh)]"
>
<div class="flex flex-col items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1364 259"
width="100%"
height="100%"
fill="currentColor"
class="w-[clamp(120px,15vw,192px)] h-[clamp(40px,6vh,64px)] drop-shadow-[0_0_10px_rgba(37,99,235,0.3)]"
>
<!-- (Logo paths preserved) -->
<path
d="M0,174V51h15.24v-17.14h16.81v-16.98h16.96V0h1266v17.23h17.13v16.81h16.98v16.96h14.88v123h-15.13v17.08h-17.08v17.08h-16.9v17.04H324.9v16.86h-16.9v16.95h-102v-17.12h-17.07v-17.05H48.73v-17.05h-16.89v-16.89H14.94v-16.89H0ZM1297.95,17.35H65.9v16.7h-17.08v17.08h-14.5v123.08h14.85v16.9h17.08v17.08h139.9v17.08h17.08v16.36h67.9v-16.72h17.08v-17.07h989.88v-17.07h17.08v-16.9h14.44V50.8h-14.75v-17.08h-16.9v-16.37Z"
/>
<path
d="M189.1,154.78v17.07h-16.9v16.75h-51.07v-16.42h-16.9v-17.07h-16.97v-84.88h16.63v-17.07h16.9v-16.84h51.07v16.5h17.07v17.07h16.7v84.89h-16.54ZM137.87,53.1v17.15h-16.6v84.86h16.97v16.61h16.89v-16.97h16.6v-84.86h-16.97v-16.79h-16.89Z"
/>
<path
d="M273.91,104.06v-16.73h50.92v16.45h16.85v68.05h-16.44v17.06h-50.96v16.88h16.4v16.96h-67.28v-16.61h16.33v-101.86h-16.38v-16.98h33.4v16.63c6.12,0,11.72,0,17.31,0,0,22.56,0,45.13,0,67.75h33.59v-67.61h-33.73Z"
/>
<path
d="M631.12,188.64v-16.36h16.53V53.2h-16.25v-16.86h118.33v33.29h-16.65v-16.36h-50.72v50.44h33.36v-16.35h16.99v50.25h-16.6v-16.33h-33.73v50.65h16.37v16.72h-67.63Z"
/>
<path
d="M596.78,103.8v84.94h-33.54v-84.39h-34.03v84.25h-33.85v-101.29h84.5v16.49h16.93Z"
/>
<path
d="M1107.12,188.71v-84.34h-34.03v84.37h-33.7v-101.41h84.42v16.41h16.86v84.96h-33.54Z"
/>
<path
d="M988.1,171.78v16.87h-67.88v-16.38h-16.87v-68.06h16.38v-16.87h68.06v16.38h16.87v68.06h-16.55ZM970.78,104.35h-33.39v67.38h33.39v-67.38Z"
/>
<path
d="M460.77,155.38v16.49h-16.58v16.83h-68.05v-16.5h-16.83v-68.05h16.49v-16.83h68.05v16.49h16.83v34.06h-67.31v33.82h33.47v-16.31h33.92ZM393.39,104.18v16.56h33.3v-16.56h-33.3Z"
/>
<path
d="M1209.13,172h-16.9v-67.9h-16.96v-16.9h16.68v-17.08h16.9v-16.82h16.9v33.58h50.98v16.91h-50.4v67.96h16.48v-16.43h50.95v16.54h-16.55v16.76h-68.08v-16.6Z"
/>
<path
d="M834.91,120.94v16.96h-16.65v33.88h16.41v16.96h-67.29v-16.63h16.34v-67.87h-16.4v-16.97h50.42v33.81h17.3l-.14-.14Z"
/>
<path
d="M835.05,121.08v-33.75h33.76v16.43h16.85v33.96h-33.43v-16.79c-6.13,0-11.73,0-17.32,0,0,0,.14.14.14.14Z"
/>
</svg>
<img
src=${assetUrl("images/OpenFrontLogo.svg")}
alt="OpenFront"
class="h-full w-auto"
/>
<div
id="game-version"
class="l-header__highlightText text-center"
+190
View File
@@ -0,0 +1,190 @@
import { LitElement, html, nothing } from "lit";
import { resolveMarkdown } from "lit-markdown";
import { customElement, state } from "lit/decorators.js";
import type { NewsItem } from "../../core/ApiSchemas";
import { getNews } from "../Api";
import { translateText } from "../Utils";
export type { NewsItem };
const DISMISSED_NEWS_KEY = "dismissedNewsItems";
const CYCLE_INTERVAL_MS = 5000;
function getDismissedIds(): Set<string> {
const raw = localStorage.getItem(DISMISSED_NEWS_KEY);
if (raw) return new Set(JSON.parse(raw));
return new Set();
}
function saveDismissedIds(ids: Set<string>): void {
localStorage.setItem(DISMISSED_NEWS_KEY, JSON.stringify([...ids]));
}
export function getVisibleNewsItems(items: NewsItem[]): NewsItem[] {
const dismissed = getDismissedIds();
return items.filter((item) => !dismissed.has(item.id));
}
const typeLabelKeys: Record<string, string> = {
tournament: "news_box.tournament",
tutorial: "news_box.tutorial",
announcement: "news_box.news",
warning: "news_box.warning",
};
const typeLabelColors: Record<string, string> = {
tournament: "bg-amber-500/20 text-amber-300",
tutorial: "bg-sky-500/20 text-sky-300",
announcement: "bg-emerald-500/20 text-emerald-300",
warning: "bg-red-500/20 text-red-300",
};
@customElement("news-box")
export class NewsBox extends LitElement {
@state() private items: NewsItem[] = [];
@state() private activeIndex = 0;
private cycleTimer: ReturnType<typeof setInterval> | null = null;
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
this.loadNews();
}
private async loadNews() {
try {
const allItems = await getNews();
// Reset stale dismissed list when all items would be hidden
const visible = getVisibleNewsItems(allItems);
if (visible.length === 0 && allItems.length > 0) {
localStorage.removeItem(DISMISSED_NEWS_KEY);
this.items = allItems;
} else {
this.items = visible;
}
this.startCycle();
} catch (e) {
console.error(e);
}
}
disconnectedCallback() {
super.disconnectedCallback();
this.stopCycle();
}
private startCycle() {
this.stopCycle();
if (this.items.length > 1) {
this.cycleTimer = setInterval(() => {
this.activeIndex = (this.activeIndex + 1) % this.items.length;
}, CYCLE_INTERVAL_MS);
}
}
private stopCycle() {
if (this.cycleTimer !== null) {
clearInterval(this.cycleTimer);
this.cycleTimer = null;
}
}
private dismiss(id: string) {
const dismissed = getDismissedIds();
dismissed.add(id);
saveDismissedIds(dismissed);
this.items = this.items.filter((item) => item.id !== id);
if (this.activeIndex >= this.items.length) {
this.activeIndex = 0;
}
this.startCycle();
}
private goTo(index: number) {
this.activeIndex = index;
this.startCycle();
}
render() {
if (this.items.length === 0) return nothing;
const item = this.items[this.activeIndex];
return html`
<div
class="px-2 py-2 bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] border-y border-white/10 lg:border-y-0 lg:rounded-xl lg:p-3"
>
<div class="flex items-center gap-3">
<span
class="shrink-0 text-[10px] font-bold tracking-wider px-2 py-0.5 rounded ${typeLabelColors[
item.type
] ?? typeLabelColors["announcement"]}"
>${translateText(
typeLabelKeys[item.type] ?? typeLabelKeys["announcement"],
)}</span
>
<div class="flex-1 min-w-0">
${item.url
? html`<a
href="${item.url}"
target="_blank"
rel="noopener noreferrer"
class="text-sm font-medium text-white hover:text-blue-300 transition-colors truncate block"
>${item.title}</a
>`
: html`<span class="text-sm font-medium text-white truncate block"
>${item.title}</span
>`}
<span
class="text-xs text-white/50 block [&_a]:text-blue-300 [&_a:hover]:text-blue-200"
>${resolveMarkdown(
item.descriptionTranslationKey
? translateText(item.descriptionTranslationKey)
: (item.description ?? ""),
)}</span
>
</div>
${this.items.length > 1
? html`
<div class="flex gap-1 shrink-0">
${this.items.map(
(_, i) => html`
<button
@click=${() => this.goTo(i)}
class="w-1.5 h-1.5 rounded-full transition-colors ${i ===
this.activeIndex
? "bg-white/60"
: "bg-white/20 hover:bg-white/40"}"
aria-label="${translateText("news_box.go_to_item", {
num: i + 1,
})}"
></button>
`,
)}
</div>
`
: nothing}
<button
@click=${() => this.dismiss(item.id)}
class="shrink-0 p-0.5 text-white/30 hover:text-white/70 transition-colors"
aria-label="${translateText("news_box.dismiss")}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-3.5 h-3.5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
</div>
`;
}
}
@@ -0,0 +1,49 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { UserMeResponse } from "../../core/ApiSchemas";
import { hasLinkedAccount } from "../Api";
@customElement("not-logged-in-warning")
export class NotLoggedInWarning extends LitElement {
@state() private linked = false;
private _onUserMe = (event: CustomEvent<UserMeResponse | false>) => {
this.linked = hasLinkedAccount(event.detail);
};
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
document.addEventListener(
"userMeResponse",
this._onUserMe as EventListener,
);
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener(
"userMeResponse",
this._onUserMe as EventListener,
);
}
render() {
if (this.linked) return html``;
return html`<div class="no-crazygames flex items-center">
<button
class="px-4 py-2 text-xs font-bold uppercase tracking-wider transition-colors duration-200 rounded-lg bg-red-500/20 text-red-400 border border-red-500/30 cursor-pointer hover:bg-red-500/30"
data-i18n="common.not_logged_in"
@click=${() => {
window.showPage?.("page-account");
}}
>
Not logged in
</button>
</div>`;
}
}
-284
View File
@@ -1,284 +0,0 @@
import { Colord } from "colord";
import { base64url } from "jose";
import { html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import {
ColorPalette,
DefaultPattern,
Pattern,
} from "../../core/CosmeticSchemas";
import { PatternDecoder } from "../../core/PatternDecoder";
import { PlayerPattern } from "../../core/Schemas";
import { translateText } from "../Utils";
export const BUTTON_WIDTH = 150;
@customElement("pattern-button")
export class PatternButton extends LitElement {
@property({ type: Boolean })
selected: boolean = false;
@property({ type: Object })
pattern: Pattern | null = null;
@property({ type: Object })
colorPalette: ColorPalette | null = null;
@property({ type: Boolean })
requiresPurchase: boolean = false;
@property({ type: Function })
onSelect?: (pattern: PlayerPattern | null) => void;
@property({ type: Function })
onPurchase?: (pattern: Pattern, colorPalette: ColorPalette | null) => void;
createRenderRoot() {
return this;
}
private translateCosmetic(prefix: string, patternName: string): string {
const translation = translateText(`${prefix}.${patternName}`);
if (translation.startsWith(prefix)) {
return patternName
.split("_")
.filter((word) => word.length > 0)
.map((word) => word[0].toUpperCase() + word.substring(1))
.join(" ");
}
return translation;
}
private handleClick() {
if (this.pattern === null) {
this.onSelect?.(null);
return;
}
this.onSelect?.({
name: this.pattern!.name,
patternData: this.pattern!.pattern,
colorPalette: this.colorPalette ?? undefined,
} satisfies PlayerPattern);
}
private handlePurchase(e: Event) {
e.stopPropagation();
if (this.pattern?.product) {
this.onPurchase?.(this.pattern, this.colorPalette ?? null);
}
}
render() {
const isDefaultPattern = this.pattern === null;
return html`
<div
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"}"
>
<button
class="group relative flex flex-col items-center w-full gap-2 rounded-lg cursor-pointer transition-all duration-200
disabled:cursor-not-allowed flex-1"
?disabled=${this.requiresPurchase}
@click=${this.handleClick}
>
<div class="flex flex-col items-center w-full">
<div
class="text-xs font-bold text-white uppercase tracking-wider mb-1 text-center truncate w-full ${this
.requiresPurchase
? "opacity-50"
: ""}"
title="${isDefaultPattern
? translateText("territory_patterns.pattern.default")
: this.translateCosmetic(
"territory_patterns.pattern",
this.pattern!.name,
)}"
>
${isDefaultPattern
? translateText("territory_patterns.pattern.default")
: this.translateCosmetic(
"territory_patterns.pattern",
this.pattern!.name,
)}
</div>
${this.colorPalette !== null
? html`
<div
class="text-[10px] font-bold text-white/40 uppercase tracking-widest mb-2 text-center truncate w-full ${this
.requiresPurchase
? "opacity-50"
: ""}"
>
${this.translateCosmetic(
"territory_patterns.color_palette",
this.colorPalette!.name,
)}
</div>
`
: html`<div class="h-[22px] mb-2 w-full"></div>`}
</div>
<div
class="w-full aspect-square flex items-center justify-center bg-white/5 rounded-lg p-2 border border-white/10 group-hover:border-white/20 transition-colors duration-200 overflow-hidden"
>
${renderPatternPreview(
this.pattern !== null
? ({
name: this.pattern!.name,
patternData: this.pattern!.pattern,
colorPalette: this.colorPalette ?? undefined,
} satisfies PlayerPattern)
: DefaultPattern,
BUTTON_WIDTH,
BUTTON_WIDTH,
)}
</div>
</button>
${this.requiresPurchase && this.pattern?.product
? html`
<div class="w-full mt-2">
<button
class="w-full px-4 py-2 bg-green-500/20 text-green-400 border border-green-500/30 rounded-lg text-xs font-bold uppercase tracking-wider cursor-pointer transition-all duration-200
hover:bg-green-500/30 hover:shadow-[0_0_15px_rgba(74,222,128,0.2)]"
@click=${this.handlePurchase}
>
${translateText("territory_patterns.purchase")}
<span class="ml-1 text-white/60"
>(${this.pattern.product.price})</span
>
</button>
</div>
`
: null}
</div>
`;
}
}
export function renderPatternPreview(
pattern: PlayerPattern | null,
width: number,
height: number,
): TemplateResult {
if (pattern === null) {
return renderBlankPreview(width, height);
}
return html`<img
src="${generatePreviewDataUrl(pattern, width, height)}"
alt="Pattern preview"
class="w-full h-full object-contain [image-rendering:pixelated] pointer-events-none"
draggable="false"
/>`;
}
function renderBlankPreview(width: number, height: number): TemplateResult {
return html`
<div
class="md:hidden flex items-center justify-center h-full w-full bg-white rounded overflow-hidden relative border border-[#ccc] box-border"
>
<div
class="grid grid-cols-2 grid-rows-2 gap-0 w-[calc(100%-1px)] h-[calc(100%-2px)] box-border"
>
<div class="bg-white border border-black/10 box-border"></div>
<div class="bg-white border border-black/10 box-border"></div>
<div class="bg-white border border-black/10 box-border"></div>
<div class="bg-white border border-black/10 box-border"></div>
</div>
</div>
<div
class="hidden md:flex items-center justify-center h-full w-full rounded overflow-hidden relative text-center p-1"
>
<span
class="text-[10px] font-black text-white/40 uppercase leading-none break-words w-full"
>
${translateText("territory_patterns.select_skin")}
</span>
</div>
`;
}
const patternCache = new Map<string, string>();
const DEFAULT_PRIMARY = new Colord("#ffffff").toRgb(); // White
const DEFAULT_SECONDARY = new Colord("#000000").toRgb(); // Black
function generatePreviewDataUrl(
pattern?: PlayerPattern,
width?: number,
height?: number,
): string {
pattern ??= DefaultPattern;
const patternLookupKey = [
pattern.name,
pattern.colorPalette?.primaryColor ?? "undefined",
pattern.colorPalette?.secondaryColor ?? "undefined",
width,
height,
].join("-");
if (patternCache.has(patternLookupKey)) {
return patternCache.get(patternLookupKey)!;
}
// Calculate canvas size
let decoder: PatternDecoder;
try {
decoder = new PatternDecoder(
{
name: pattern.name,
patternData: pattern.patternData,
colorPalette: pattern.colorPalette,
},
base64url.decode,
);
} catch (e) {
console.error("Error decoding pattern", e);
return "";
}
const scaledWidth = decoder.scaledWidth();
const scaledHeight = decoder.scaledHeight();
width =
width === undefined
? scaledWidth
: Math.max(1, Math.floor(width / scaledWidth)) * scaledWidth;
height =
height === undefined
? scaledHeight
: Math.max(1, Math.floor(height / scaledHeight)) * scaledHeight;
// Create the canvas
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("2D context not supported");
// Create an image
const imageData = ctx.createImageData(width, height);
const data = imageData.data;
const primary = pattern.colorPalette?.primaryColor
? new Colord(pattern.colorPalette.primaryColor).toRgb()
: DEFAULT_PRIMARY;
const secondary = pattern.colorPalette?.secondaryColor
? new Colord(pattern.colorPalette.secondaryColor).toRgb()
: DEFAULT_SECONDARY;
let i = 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const rgba = decoder.isPrimary(x, y) ? primary : secondary;
data[i++] = rgba.r;
data[i++] = rgba.g;
data[i++] = rgba.b;
data[i++] = 255; // Alpha
}
}
// Create a data URL
ctx.putImageData(imageData, 0, 0);
const dataUrl = canvas.toDataURL("image/png");
patternCache.set(patternLookupKey, dataUrl);
return dataUrl;
}
+129
View File
@@ -0,0 +1,129 @@
import { Colord } from "colord";
import { base64url } from "jose";
import { html, TemplateResult } from "lit";
import { DefaultPattern } from "../../core/CosmeticSchemas";
import { PatternDecoder } from "../../core/PatternDecoder";
import { PlayerPattern } from "../../core/Schemas";
import { translateText } from "../Utils";
export function renderPatternPreview(
pattern: PlayerPattern | null,
width: number,
height: number,
): TemplateResult {
if (pattern === null) {
return renderBlankPreview();
}
return html`<img
src="${generatePreviewDataUrl(pattern, width, height)}"
alt="Pattern preview"
class="w-full h-full object-contain [image-rendering:pixelated] pointer-events-none"
draggable="false"
/>`;
}
function renderBlankPreview(): TemplateResult {
return html`
<div
class="md:hidden flex items-center justify-center h-full w-full bg-white rounded overflow-hidden relative border border-[#ccc] box-border"
>
<div
class="grid grid-cols-2 grid-rows-2 gap-0 w-[calc(100%-1px)] h-[calc(100%-2px)] box-border"
>
<div class="bg-white border border-black/10 box-border"></div>
<div class="bg-white border border-black/10 box-border"></div>
<div class="bg-white border border-black/10 box-border"></div>
<div class="bg-white border border-black/10 box-border"></div>
</div>
</div>
<div
class="hidden md:flex items-center justify-center h-full w-full rounded overflow-hidden relative text-center p-1"
>
<span
class="text-[10px] font-black text-white/40 uppercase leading-none break-words w-full"
>
${translateText("territory_patterns.select_skin")}
</span>
</div>
`;
}
const patternCache = new Map<string, string>();
const DEFAULT_PRIMARY = new Colord("#ffffff").toRgb();
const DEFAULT_SECONDARY = new Colord("#000000").toRgb();
export function generatePreviewDataUrl(
pattern?: PlayerPattern,
width?: number,
height?: number,
): string {
pattern ??= DefaultPattern;
const patternLookupKey = [
pattern.name,
pattern.colorPalette?.primaryColor ?? "undefined",
pattern.colorPalette?.secondaryColor ?? "undefined",
width,
height,
].join("-");
if (patternCache.has(patternLookupKey)) {
return patternCache.get(patternLookupKey)!;
}
let decoder: PatternDecoder;
try {
decoder = new PatternDecoder(
{
name: pattern.name,
patternData: pattern.patternData,
colorPalette: pattern.colorPalette,
},
base64url.decode,
);
} catch (e) {
console.error("Error decoding pattern", e);
return "";
}
const scaledWidth = decoder.scaledWidth();
const scaledHeight = decoder.scaledHeight();
width =
width === undefined
? scaledWidth
: Math.max(1, Math.floor(width / scaledWidth)) * scaledWidth;
height =
height === undefined
? scaledHeight
: Math.max(1, Math.floor(height / scaledHeight)) * scaledHeight;
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("2D context not supported");
const imageData = ctx.createImageData(width, height);
const data = imageData.data;
const primary = pattern.colorPalette?.primaryColor
? new Colord(pattern.colorPalette.primaryColor).toRgb()
: DEFAULT_PRIMARY;
const secondary = pattern.colorPalette?.secondaryColor
? new Colord(pattern.colorPalette.secondaryColor).toRgb()
: DEFAULT_SECONDARY;
let i = 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const rgba = decoder.isPrimary(x, y) ? primary : secondary;
data[i++] = rgba.r;
data[i++] = rgba.g;
data[i++] = rgba.b;
data[i++] = 255;
}
}
ctx.putImageData(imageData, 0, 0);
const dataUrl = canvas.toDataURL("image/png");
patternCache.set(patternLookupKey, dataUrl);
return dataUrl;
}
+17 -42
View File
@@ -1,5 +1,7 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { assetUrl } from "../../core/AssetUrls";
import "./NewsBox";
@customElement("play-page")
export class PlayPage extends LitElement {
@@ -11,7 +13,7 @@ export class PlayPage extends LitElement {
return html`
<div
id="page-play"
class="flex flex-col gap-2 w-full px-0 lg:px-4 lg:my-auto min-h-0"
class="flex flex-col gap-2 w-full px-0 lg:px-4 min-h-0"
>
<token-login class="absolute"></token-login>
@@ -48,48 +50,13 @@ export class PlayPage extends LitElement {
</button>
<div
class="col-start-2 flex items-center justify-center text-[#2563eb] min-w-0"
class="col-start-2 flex items-center justify-center text-[#0073b7] min-w-0"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1364 259"
fill="currentColor"
class="h-6 w-auto drop-shadow-[0_0_10px_rgba(37,99,235,0.3)] shrink-0"
>
<path
d="M0,174V51h15.24v-17.14h16.81v-16.98h16.96V0h1266v17.23h17.13v16.81h16.98v16.96h14.88v123h-15.13v17.08h-17.08v17.08h-16.9v17.04H324.9v16.86h-16.9v16.95h-102v-17.12h-17.07v-17.05H48.73v-17.05h-16.89v-16.89H14.94v-16.89H0ZM1297.95,17.35H65.9v16.7h-17.08v17.08h-14.5v123.08h14.85v16.9h17.08v17.08h139.9v17.08h17.08v16.36h67.9v-16.72h17.08v-17.07h989.88v-17.07h17.08v-16.9h14.44V50.8h-14.75v-17.08h-16.9v-16.37Z"
/>
<path
d="M189.1,154.78v17.07h-16.9v16.75h-51.07v-16.42h-16.9v-17.07h-16.97v-84.88h16.63v-17.07h16.9v-16.84h51.07v16.5h17.07v17.07h16.7v84.89h-16.54ZM137.87,53.1v17.15h-16.6v84.86h16.97v16.61h16.89v-16.97h16.6v-84.86h-16.97v-16.79h-16.89Z"
/>
<path
d="M273.91,104.06v-16.73h50.92v16.45h16.85v68.05h-16.44v17.06h-50.96v16.88h16.4v16.96h-67.28v-16.61h16.33v-101.86h-16.38v-16.98h33.4v16.63c6.12,0,11.72,0,17.31,0,0,22.56,0,45.13,0,67.75h33.59v-67.61h-33.73Z"
/>
<path
d="M631.12,188.64v-16.36h16.53V53.2h-16.25v-16.86h118.33v33.29h-16.65v-16.36h-50.72v50.44h33.36v-16.35h16.99v50.25h-16.6v-16.33h-33.73v50.65h16.37v16.72h-67.63Z"
/>
<path
d="M596.78,103.8v84.94h-33.54v-84.39h-34.03v84.25h-33.85v-101.29h84.5v16.49h16.93Z"
/>
<path
d="M1107.12,188.71v-84.34h-34.03v84.37h-33.7v-101.41h84.42v16.41h16.86v84.96h-33.54Z"
/>
<path
d="M988.1,171.78v16.87h-67.88v-16.38h-16.87v-68.06h16.38v-16.87h68.06v16.38h16.87v68.06h-16.55ZM970.78,104.35h-33.39v67.38h33.39v-67.38Z"
/>
<path
d="M460.77,155.38v16.49h-16.58v16.83h-68.05v-16.5h-16.83v-68.05h16.49v-16.83h68.05v16.49h16.83v34.06h-67.31v33.82h33.47v-16.31h33.92ZM393.39,104.18v16.56h33.3v-16.56h-33.3Z"
/>
<path
d="M1209.13,172h-16.9v-67.9h-16.96v-16.9h16.68v-17.08h16.9v-16.82h16.9v33.58h50.98v16.91h-50.4v67.96h16.48v-16.43h50.95v16.54h-16.55v16.76h-68.08v-16.6Z"
/>
<path
d="M834.91,120.94v16.96h-16.65v33.88h16.41v16.96h-67.29v-16.63h16.34v-67.87h-16.4v-16.97h50.42v33.81h17.3l-.14-.14Z"
/>
<path
d="M835.05,121.08v-33.75h33.76v16.43h16.85v33.96h-33.43v-16.79c-6.13,0-11.73,0-17.32,0,0,0,.14.14.14.14Z"
/>
</svg>
<img
src=${assetUrl("images/OpenFrontLogo.svg")}
alt="OpenFront"
class="h-full w-auto"
/>
</div>
<div
@@ -107,6 +74,9 @@ export class PlayPage extends LitElement {
class="lg:hidden h-[calc(env(safe-area-inset-top)+56px)] lg:col-span-2 -mb-4"
></div>
<!-- News box above username -->
<news-box class="lg:col-span-2"></news-box>
<!-- Username: left col -->
<div
class="px-2 py-2 bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] border-y border-white/10 overflow-visible lg:flex lg:items-center lg:gap-x-2 lg:h-[60px] lg:p-3 lg:relative lg:z-20 lg:border-y-0 lg:rounded-xl"
@@ -121,6 +91,11 @@ export class PlayPage extends LitElement {
adaptive-size
class="shrink-0 lg:hidden"
></pattern-input>
<flag-input
id="flag-input-mobile"
show-select-label
class="shrink-0 lg:hidden h-10 w-10"
></flag-input>
</div>
</div>
+55
View File
@@ -0,0 +1,55 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { assetUrl } from "../../core/AssetUrls";
const STYLE_ID = "plutonium-icon-styles";
if (!document.getElementById(STYLE_ID)) {
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
@keyframes plutonium-pulse {
0% { filter: drop-shadow(0 0 4px rgba(34,197,94,0.6)) drop-shadow(0 0 8px rgba(34,197,94,0.3)); scale: 1; }
50% { filter: drop-shadow(0 0 10px rgba(34,197,94,0.9)) drop-shadow(0 0 20px rgba(34,197,94,0.5)) drop-shadow(0 0 30px rgba(34,197,94,0.2)); scale: 1.04; }
100% { filter: drop-shadow(0 0 4px rgba(34,197,94,0.6)) drop-shadow(0 0 8px rgba(34,197,94,0.3)); scale: 1; }
}
@keyframes plutonium-rotate {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes plutonium-jiggle {
0%, 100% { translate: 0 0; }
25% { translate: -0.4px 0.3px; }
50% { translate: 0.3px -0.4px; }
75% { translate: -0.3px -0.3px; }
}
`;
document.head.appendChild(style);
}
@customElement("plutonium-icon")
export class PlutoniumIcon extends LitElement {
@property({ type: Number })
size: number = 48;
createRenderRoot() {
return this;
}
render() {
return html`
<div
class="inline-flex items-center justify-center"
style="width:${this.size}px; height:${this
.size}px; animation: plutonium-pulse 2s ease-in-out infinite, plutonium-jiggle 0.15s linear infinite;"
>
<img
src=${assetUrl("images/PlutoniumIcon.svg")}
alt="Plutonium"
style="width:${this.size}px; height:${this
.size}px; animation: plutonium-rotate 7s linear infinite;"
draggable="false"
/>
</div>
`;
}
}
+291
View File
@@ -0,0 +1,291 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Product } from "../../core/CosmeticSchemas";
import { translateText } from "../Utils";
import "./CapIcon";
import "./PlutoniumIcon";
const PURCHASE_STYLE_ID = "purchase-button-styles";
if (!document.getElementById(PURCHASE_STYLE_ID)) {
const style = document.createElement("style");
style.id = PURCHASE_STYLE_ID;
style.textContent = `
@keyframes purchase-streak {
0% { left: -60%; opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
100% { left: 160%; opacity: 0; }
}
.purchase-sparkle-streak {
pointer-events: none;
position: absolute;
top: 0;
left: -60%;
width: 40%;
height: 100%;
background: linear-gradient(90deg, transparent 0%, rgba(134,239,172,0.5) 50%, transparent 100%);
transform: skewX(-15deg);
opacity: 0;
}
cosmetic-container:hover .purchase-sparkle-streak {
animation: purchase-streak 0.7s ease-in-out;
}
cosmetic-container:hover .purchase-sparkle-btn {
background: rgb(34,197,94);
border-color: rgb(74,222,128);
color: white;
box-shadow: 0 0 20px rgba(74,222,128,0.6);
}
cosmetic-container:hover .purchase-sparkle-btn-hard {
background: rgb(22,163,74);
border-color: rgb(74,222,128);
color: white;
box-shadow: 0 0 20px rgba(74,222,128,0.6);
}
cosmetic-container:hover .purchase-sparkle-btn-soft {
background: rgb(180,83,9);
border-color: rgb(217,119,6);
color: white;
box-shadow: 0 0 20px rgba(217,119,6,0.6);
}
@keyframes purchase-pulse {
0% { box-shadow: 0 0 15px rgba(74,222,128,0.6), 0 0 30px rgba(34,197,94,0.3); }
50% { box-shadow: 0 0 25px rgba(74,222,128,0.9), 0 0 50px rgba(34,197,94,0.5); }
100% { box-shadow: 0 0 15px rgba(74,222,128,0.6), 0 0 30px rgba(34,197,94,0.3); }
}
.purchase-sparkle-btn:hover {
background: rgb(22,163,74) !important;
border-color: rgb(74,222,128) !important;
color: white !important;
animation: purchase-pulse 1.2s ease-in-out infinite !important;
}
.purchase-sparkle-btn-hard:hover {
background: rgb(22,163,74) !important;
border-color: rgb(74,222,128) !important;
color: white !important;
animation: purchase-pulse 1.2s ease-in-out infinite !important;
}
@keyframes purchase-pulse-soft {
0% { box-shadow: 0 0 15px rgba(217,119,6,0.6), 0 0 30px rgba(180,83,9,0.3); }
50% { box-shadow: 0 0 25px rgba(217,119,6,0.9), 0 0 50px rgba(180,83,9,0.5); }
100% { box-shadow: 0 0 15px rgba(217,119,6,0.6), 0 0 30px rgba(180,83,9,0.3); }
}
.purchase-sparkle-btn-soft:hover {
background: rgb(180,83,9) !important;
border-color: rgb(217,119,6) !important;
color: white !important;
animation: purchase-pulse-soft 1.2s ease-in-out infinite !important;
}
@keyframes purchase-ember-0 {
0% { transform: translateY(0) translateX(0) scale(1); opacity: 0.9; }
100% { transform: translateY(-35px) translateX(5px) scale(0.2); opacity: 0; }
}
@keyframes purchase-ember-1 {
0% { transform: translateY(0) translateX(0) scale(1); opacity: 0.9; }
100% { transform: translateY(-30px) translateX(-6px) scale(0.3); opacity: 0; }
}
@keyframes purchase-ember-2 {
0% { transform: translateY(0) translateX(0) scale(1); opacity: 0.9; }
100% { transform: translateY(-40px) translateX(3px) scale(0.2); opacity: 0; }
}
@keyframes purchase-ember-3 {
0% { transform: translateY(0) translateX(0) scale(1); opacity: 0.9; }
100% { transform: translateY(-28px) translateX(-4px) scale(0.3); opacity: 0; }
}
.purchase-ember {
pointer-events: none;
position: absolute;
top: 0;
width: 3px;
height: 3px;
border-radius: 50%;
background: rgba(74,222,128,0.9);
box-shadow: 0 0 4px rgba(74,222,128,0.8);
opacity: 0;
display: none;
}
.purchase-ember-0 { left: 20%; animation: purchase-ember-0 1.2s ease-out infinite; }
.purchase-ember-1 { left: 40%; animation: purchase-ember-1 1.5s ease-out infinite 0.25s; }
.purchase-ember-2 { left: 60%; animation: purchase-ember-2 1.3s ease-out infinite 0.5s; }
.purchase-ember-3 { left: 80%; animation: purchase-ember-3 1.6s ease-out infinite 0.15s; }
cosmetic-container:hover .purchase-ember {
display: block;
}
@keyframes purchase-burst-a { 0% { transform: translateY(0) translateX(0) scale(1.2); opacity:1; } 100% { transform: translateY(-70px) translateX(14px) scale(0); opacity:0; } }
@keyframes purchase-burst-b { 0% { transform: translateY(0) translateX(0) scale(1.2); opacity:1; } 100% { transform: translateY(-60px) translateX(-12px) scale(0); opacity:0; } }
@keyframes purchase-burst-c { 0% { transform: translateY(0) translateX(0) scale(1.2); opacity:1; } 100% { transform: translateY(-80px) translateX(8px) scale(0); opacity:0; } }
@keyframes purchase-burst-d { 0% { transform: translateY(0) translateX(0) scale(1.2); opacity:1; } 100% { transform: translateY(-55px) translateX(-16px) scale(0); opacity:0; } }
@keyframes purchase-burst-e { 0% { transform: translateY(0) translateX(0) scale(1.2); opacity:1; } 100% { transform: translateY(-75px) translateX(18px) scale(0); opacity:0; } }
@keyframes purchase-burst-f { 0% { transform: translateY(0) translateX(0) scale(1.2); opacity:1; } 100% { transform: translateY(-65px) translateX(-6px) scale(0); opacity:0; } }
.purchase-burst {
pointer-events: none;
position: absolute;
top: 0;
width: 4px;
height: 4px;
border-radius: 50%;
background: rgba(74,222,128,1);
box-shadow: 0 0 6px rgba(74,222,128,0.9), 0 0 2px rgba(255,255,255,0.5);
opacity: 0;
display: none;
}
.purchase-burst-0 { left: 3%; animation: purchase-burst-a 0.9s ease-out infinite 0.00s; }
.purchase-burst-1 { left: 8%; animation: purchase-burst-d 1.1s ease-out infinite 0.73s; }
.purchase-burst-2 { left: 12%; animation: purchase-burst-c 0.95s ease-out infinite 0.41s; }
.purchase-burst-3 { left: 16%; animation: purchase-burst-f 1.05s ease-out infinite 0.17s; }
.purchase-burst-4 { left: 20%; animation: purchase-burst-b 0.85s ease-out infinite 0.89s; }
.purchase-burst-5 { left: 24%; animation: purchase-burst-e 1.0s ease-out infinite 0.53s; }
.purchase-burst-6 { left: 28%; animation: purchase-burst-a 1.1s ease-out infinite 0.29s; }
.purchase-burst-7 { left: 32%; animation: purchase-burst-c 0.9s ease-out infinite 0.97s; }
.purchase-burst-8 { left: 36%; animation: purchase-burst-f 1.05s ease-out infinite 0.61s; }
.purchase-burst-9 { left: 40%; animation: purchase-burst-d 0.95s ease-out infinite 0.07s; }
.purchase-burst-10 { left: 44%; animation: purchase-burst-b 1.0s ease-out infinite 0.83s; }
.purchase-burst-11 { left: 48%; animation: purchase-burst-e 0.85s ease-out infinite 0.37s; }
.purchase-burst-12 { left: 52%; animation: purchase-burst-a 1.1s ease-out infinite 0.67s; }
.purchase-burst-13 { left: 56%; animation: purchase-burst-f 0.9s ease-out infinite 0.11s; }
.purchase-burst-14 { left: 60%; animation: purchase-burst-c 1.05s ease-out infinite 0.79s; }
.purchase-burst-15 { left: 64%; animation: purchase-burst-d 0.95s ease-out infinite 0.47s; }
.purchase-burst-16 { left: 68%; animation: purchase-burst-b 1.0s ease-out infinite 0.23s; }
.purchase-burst-17 { left: 72%; animation: purchase-burst-e 0.85s ease-out infinite 1.03s; }
.purchase-burst-18 { left: 76%; animation: purchase-burst-a 1.1s ease-out infinite 0.57s; }
.purchase-burst-19 { left: 80%; animation: purchase-burst-f 0.95s ease-out infinite 0.31s; }
.purchase-burst-20 { left: 6%; animation: purchase-burst-b 0.92s ease-out infinite 0.15s; }
.purchase-burst-21 { left: 14%; animation: purchase-burst-e 1.08s ease-out infinite 0.86s; }
.purchase-burst-22 { left: 22%; animation: purchase-burst-a 0.88s ease-out infinite 0.44s; }
.purchase-burst-23 { left: 30%; animation: purchase-burst-d 1.02s ease-out infinite 0.71s; }
.purchase-burst-24 { left: 38%; animation: purchase-burst-f 0.93s ease-out infinite 0.03s; }
.purchase-burst-25 { left: 46%; animation: purchase-burst-c 1.07s ease-out infinite 0.59s; }
.purchase-burst-26 { left: 54%; animation: purchase-burst-b 0.87s ease-out infinite 0.92s; }
.purchase-burst-27 { left: 62%; animation: purchase-burst-e 0.98s ease-out infinite 0.26s; }
.purchase-burst-28 { left: 70%; animation: purchase-burst-a 1.12s ease-out infinite 0.64s; }
.purchase-burst-29 { left: 78%; animation: purchase-burst-d 0.91s ease-out infinite 0.38s; }
.purchase-burst-30 { left: 84%; animation: purchase-burst-c 1.03s ease-out infinite 0.77s; }
.purchase-burst-31 { left: 88%; animation: purchase-burst-f 0.86s ease-out infinite 0.09s; }
.purchase-burst-32 { left: 92%; animation: purchase-burst-b 1.06s ease-out infinite 0.52s; }
.purchase-burst-33 { left: 96%; animation: purchase-burst-e 0.94s ease-out infinite 0.81s; }
.purchase-burst-34 { left: 10%; animation: purchase-burst-d 0.89s ease-out infinite 0.34s; }
.purchase-burst-35 { left: 26%; animation: purchase-burst-a 1.04s ease-out infinite 0.96s; }
.purchase-burst-36 { left: 42%; animation: purchase-burst-f 0.91s ease-out infinite 0.19s; }
.purchase-burst-37 { left: 58%; animation: purchase-burst-c 1.09s ease-out infinite 0.69s; }
.purchase-burst-38 { left: 74%; animation: purchase-burst-b 0.87s ease-out infinite 0.46s; }
.purchase-burst-39 { left: 90%; animation: purchase-burst-e 1.01s ease-out infinite 0.13s; }
.purchase-btn-wrap:hover .purchase-burst {
display: block;
}
`;
document.head.appendChild(style);
}
@customElement("purchase-button")
export class PurchaseButton extends LitElement {
@property({ type: Object })
product: Product | null = null;
@property({ type: Number })
priceHard: number | null = null;
@property({ type: Number })
priceSoft: number | null = null;
@property({ type: String })
rarity: string = "common";
@property({ type: Function })
onPurchaseDollar?: () => void;
@property({ type: Function })
onPurchaseHard?: () => void;
@property({ type: Function })
onPurchaseSoft?: () => void;
createRenderRoot() {
return this;
}
private handleClick(e: Event, handler?: () => void) {
e.stopPropagation();
if (!handler) return;
const container = this.closest("cosmetic-container") as HTMLElement | null;
if (container && !container.querySelector(".cosmetic-loading-overlay")) {
const overlay = document.createElement("div");
overlay.className = "cosmetic-loading-overlay";
overlay.innerHTML = `<div class="cosmetic-loading-spinner"></div>`;
container.appendChild(overlay);
}
Promise.resolve(handler()).finally(() => {
container?.querySelector(".cosmetic-loading-overlay")?.remove();
});
}
private renderDollarButton() {
return html`
<button
class="purchase-sparkle-btn relative overflow-hidden w-full px-4 py-2 bg-green-500/20 text-green-400 border border-green-500/30 rounded-lg text-xs font-bold uppercase tracking-wider cursor-pointer transition-all duration-200
hover:bg-green-500 hover:border-green-400 hover:text-white hover:shadow-[0_0_20px_rgba(74,222,128,0.6)]"
@click=${(e: Event) => this.handleClick(e, this.onPurchaseDollar)}
>
<span class="purchase-sparkle-streak"></span>
${translateText("territory_patterns.purchase")}
<span class="ml-1 text-white/50">(${this.product!.price})</span>
</button>
`;
}
private renderHardButton() {
return html`
<button
class="purchase-sparkle-btn-hard relative overflow-hidden w-full px-2 py-1.5 bg-green-500/20 text-green-400 border border-green-500/30 rounded-lg text-base font-bold cursor-pointer transition-all duration-200 flex items-center justify-center gap-2
hover:bg-green-500 hover:border-green-400 hover:text-white hover:shadow-[0_0_20px_rgba(74,222,128,0.6)]"
@click=${(e: Event) => this.handleClick(e, this.onPurchaseHard)}
>
<plutonium-icon .size=${20} style="margin-top:3px"></plutonium-icon>
${this.priceHard!.toLocaleString()}
</button>
`;
}
private renderSoftButton() {
return html`
<button
class="purchase-sparkle-btn-soft relative overflow-hidden w-full px-2 py-1.5 bg-amber-700/20 text-amber-600 border border-amber-700/30 rounded-lg text-base font-bold cursor-pointer transition-all duration-200 flex items-center justify-center gap-2
hover:bg-amber-700 hover:border-amber-600 hover:text-white hover:shadow-[0_0_20px_rgba(217,119,6,0.6)]"
@click=${(e: Event) => this.handleClick(e, this.onPurchaseSoft)}
>
<cap-icon .size=${22} style="margin-top:3px"></cap-icon>
${this.priceSoft!.toLocaleString()}
</button>
`;
}
render() {
const hasDollar = this.product && this.onPurchaseDollar;
const hasHard = this.priceHard !== null && this.onPurchaseHard;
const hasSoft = this.priceSoft !== null && this.onPurchaseSoft;
if (!hasDollar && !hasHard && !hasSoft) return nothing;
return html`
<div class="no-crazygames w-full mt-2 relative purchase-btn-wrap">
${this.rarity !== "common"
? html`<span class="purchase-ember purchase-ember-0"></span>
<span class="purchase-ember purchase-ember-1"></span>
<span class="purchase-ember purchase-ember-2"></span>
<span class="purchase-ember purchase-ember-3"></span>
${Array.from(
{ length: 40 },
(_, i) =>
html`<span
class="purchase-burst purchase-burst-${i}"
></span>`,
)}`
: null}
<div class="flex flex-col gap-1 w-full">
${hasDollar ? this.renderDollarButton() : null}
${hasHard ? this.renderHardButton() : null}
${hasSoft ? this.renderSoftButton() : null}
</div>
</div>
`;
}
}
@@ -14,7 +14,7 @@ export class OButton extends LitElement {
@property({ type: Boolean }) fill = false;
@property({ type: Boolean }) submit = false;
private static readonly BASE_CLASS =
"bg-sky-600 hover:bg-sky-700 text-white font-bold uppercase tracking-wider px-4 py-3 rounded-xl transition-all duration-300 transform hover:-translate-y-px outline-none border border-transparent text-center text-base lg:text-lg whitespace-normal break-words leading-tight overflow-hidden relative";
"bg-[#0073b7] hover:bg-sky-700 text-white font-bold uppercase tracking-wider px-4 py-3 rounded-xl transition-all duration-300 transform hover:-translate-y-px outline-none border border-transparent text-center text-base lg:text-lg whitespace-normal break-words leading-tight overflow-hidden relative";
createRenderRoot() {
return this;
@@ -100,17 +100,32 @@ export class SettingKeybind extends LitElement {
return;
}
// Don't capture lone modifier keys — wait for the actual key
if (
e.code === "ShiftLeft" ||
e.code === "ShiftRight" ||
e.code === "ControlLeft" ||
e.code === "ControlRight" ||
e.code === "AltLeft" ||
e.code === "AltRight" ||
e.code === "MetaLeft" ||
e.code === "MetaRight"
) {
return;
}
// Prevent default only for keys we're actually capturing
e.preventDefault();
const code = e.code;
const code = e.shiftKey ? `Shift+${e.code}` : e.code;
const displayKey = e.shiftKey ? `Shift+${e.key.toUpperCase()}` : e.key;
const prevValue = this.value;
// Temporarily set the value to the new code for validation in parent
this.value = code;
const event = new CustomEvent("change", {
detail: { action: this.action, value: code, key: e.key, prevValue },
detail: { action: this.action, value: code, key: displayKey, prevValue },
bubbles: true,
composed: true,
});
@@ -66,9 +66,13 @@ export class SettingSlider extends LitElement {
</div>
<div
class="flex flex-col items-start sm:items-end gap-2 shrink-0 w-full sm:w-[200px]"
class="flex flex-col items-start sm:items-end gap-2 shrink-0 sm:w-auto sm:w-[200px]"
>
<div class="flex items-center gap-2 w-full">
<span
class="text-white font-bold text-sm shrink-0 text-right min-w-[3ch]"
>${this.value}%</span
>
<input
type="range"
class="flex-1 w-auto appearance-none h-2 bg-transparent rounded outline-none
@@ -16,13 +16,6 @@ export class SettingToggle extends LitElement {
private handleChange(e: Event) {
const input = e.target as HTMLInputElement;
this.checked = input.checked;
this.dispatchEvent(
new CustomEvent("change", {
detail: { checked: this.checked },
bubbles: true,
composed: true,
}),
);
}
render() {
@@ -31,15 +31,20 @@ export class DiscordUserHeader extends LitElement {
}
render() {
const defaultAvatar = "https://cdn.discordapp.com/embed/avatars/0.png";
const imgSrc = this.avatarUrl ?? defaultAvatar;
return html`
<div class="flex items-center gap-2">
${this.avatarUrl
${this._data
? html`
<div class="p-[3px] rounded-full bg-gray-500">
<img
class="w-12 h-12 rounded-full block"
src="${this.avatarUrl}"
src="${imgSrc}"
alt="${translateText("discord_user_header.avatar_alt")}"
@error=${(e: Event) => {
(e.target as HTMLImageElement).src = defaultAvatar;
}}
/>
</div>
`
@@ -5,6 +5,7 @@ import {
Difficulty,
GameMode,
GameType,
RankedType,
isDifficulty,
isGameMode,
isGameType,
@@ -17,11 +18,12 @@ import "./PlayerStatsTable";
@customElement("player-stats-tree-view")
export class PlayerStatsTreeView extends LitElement {
@property({ type: Object }) statsTree?: PlayerStatsTree;
@state() selectedType: GameType = GameType.Public;
@state() selectedType: GameType | "Ranked" = GameType.Public;
@state() selectedMode: GameMode = GameMode.FFA;
@state() selectedDifficulty: Difficulty = Difficulty.Medium;
@state() selectedRankedType: RankedType = RankedType.OneVOne;
private get typeNode() {
if (this.selectedType === "Ranked") return undefined;
return this.statsTree?.[this.selectedType];
}
@@ -33,9 +35,20 @@ export class PlayerStatsTreeView extends LitElement {
return this.selectedType === GameType.Public;
}
private get availableTypes(): GameType[] {
private get availableTypes(): (GameType | "Ranked")[] {
if (!this.statsTree) return [];
return Object.keys(this.statsTree).filter(isGameType);
const types: (GameType | "Ranked")[] = Object.keys(this.statsTree).filter(
(k): k is GameType =>
isGameType(k) &&
Object.keys(this.statsTree![k as GameType] ?? {}).length > 0,
);
if (
this.statsTree.Ranked &&
Object.keys(this.statsTree.Ranked).length > 0
) {
types.push("Ranked");
}
return types;
}
private get availableModes(): GameMode[] {
@@ -43,6 +56,13 @@ export class PlayerStatsTreeView extends LitElement {
return Object.keys(this.typeNode).filter(isGameMode);
}
private get availableRankedTypes(): RankedType[] {
if (!this.statsTree?.Ranked) return [];
return Object.keys(this.statsTree.Ranked).filter((k): k is RankedType =>
Object.values(RankedType).includes(k as RankedType),
);
}
private get availableDifficulties(): Difficulty[] {
if (!this.modeNode) return [];
return Object.keys(this.modeNode).filter(isDifficulty);
@@ -54,11 +74,22 @@ export class PlayerStatsTreeView extends LitElement {
: translateText("game_mode.teams");
}
private labelForRankedType(r: RankedType) {
switch (r) {
case RankedType.OneVOne:
return translateText("player_stats_tree.ranked_1v1");
}
}
createRenderRoot() {
return this;
}
private getSelectedLeaf(): PlayerStatsLeaf | null {
if (this.selectedType === "Ranked") {
return this.statsTree?.Ranked?.[this.selectedRankedType] ?? null;
}
const modeNode = this.modeNode;
if (!modeNode) return null;
@@ -91,9 +122,19 @@ export class PlayerStatsTreeView extends LitElement {
private syncSelection(): void {
const types = this.availableTypes;
if (types.length && !types.includes(this.selectedType)) {
if (types.length && !types.includes(this.selectedType as GameType)) {
this.selectedType = types[0];
}
if (this.selectedType === "Ranked") {
const rankedTypes = this.availableRankedTypes;
if (
rankedTypes.length &&
!rankedTypes.includes(this.selectedRankedType)
) {
this.selectedRankedType = rankedTypes[0];
}
return;
}
const modes = this.availableModes;
if (modes.length && !modes.includes(this.selectedMode)) {
this.selectedMode = modes[0];
@@ -113,13 +154,14 @@ export class PlayerStatsTreeView extends LitElement {
changedProperties.has("statsTree") ||
changedProperties.has("selectedType") ||
changedProperties.has("selectedMode") ||
changedProperties.has("selectedDifficulty")
changedProperties.has("selectedDifficulty") ||
changedProperties.has("selectedRankedType")
) {
this.syncSelection();
}
}
private setGameType(t: GameType) {
private setGameType(t: GameType | "Ranked") {
if (this.selectedType === t) return;
this.selectedType = t;
this.requestUpdate();
@@ -131,6 +173,12 @@ export class PlayerStatsTreeView extends LitElement {
this.requestUpdate();
}
private setRankedType(r: RankedType) {
if (this.selectedRankedType === r) return;
this.selectedRankedType = r;
this.requestUpdate();
}
private setDifficulty(d: Difficulty) {
if (this.selectedDifficulty === d) return;
this.selectedDifficulty = d;
@@ -215,6 +263,7 @@ export class PlayerStatsTreeView extends LitElement {
const types = this.availableTypes;
const modes = this.availableModes;
const diffs = this.availableDifficulties;
const rankedTypes = this.availableRankedTypes;
const leaf = this.getSelectedLeaf();
const wlr = leaf
? leaf.losses === 0n
@@ -239,17 +288,40 @@ export class PlayerStatsTreeView extends LitElement {
: "bg-white/5 border-white/10 text-gray-400 hover:bg-white/10 hover:text-white"}"
@click=${() => this.setGameType(t)}
>
${t === GameType.Public
? translateText("player_stats_tree.public")
: t === GameType.Private
? translateText("player_stats_tree.private")
: translateText("player_stats_tree.solo")}
${t === "Ranked"
? translateText("player_stats_tree.ranked")
: t === GameType.Public
? translateText("player_stats_tree.public")
: t === GameType.Private
? translateText("player_stats_tree.private")
: translateText("player_stats_tree.solo")}
</button>
`,
)}
</div>
<div class="flex gap-2">
<!-- Ranked type selector -->
${this.selectedType === "Ranked" && rankedTypes.length
? html`<div
class="flex gap-1 bg-black/20 rounded-md p-1 border border-white/5"
>
${rankedTypes.map(
(r) => html`
<button
class="text-xs px-3 py-1 rounded-sm transition-colors ${this
.selectedRankedType === r
? "bg-white/20 text-white font-bold"
: "text-gray-400 hover:text-white"}"
@click=${() => this.setRankedType(r)}
>
${this.labelForRankedType(r)}
</button>
`,
)}
</div>`
: html``}
<!-- Mode selector -->
${modes.length
? html`<div
+4 -1
View File
@@ -51,6 +51,7 @@ export function createRenderer(
canvas: HTMLCanvasElement,
game: GameView,
eventBus: EventBus,
playerRole: string | null,
): GameRenderer {
const transformHandler = new TransformHandler(game, eventBus, canvas);
const userSettings = new UserSettings();
@@ -204,6 +205,8 @@ export function createRenderer(
playerPanel.emojiTable = emojiTable;
playerPanel.uiState = uiState;
playerPanel.setRole(playerRole);
const chatModal = document.querySelector("chat-modal") as ChatModal;
if (!(chatModal instanceof ChatModal)) {
console.error("chat modal not found");
@@ -273,7 +276,7 @@ export function createRenderer(
// Not grouping the layers may cause excessive calls to context.save() and context.restore().
const layers: Layer[] = [
new TerrainLayer(game, transformHandler),
new TerritoryLayer(game, eventBus, transformHandler, userSettings),
new TerritoryLayer(game, eventBus, transformHandler),
new RailroadLayer(game, eventBus, transformHandler, uiState),
new CoordinateGridLayer(game, eventBus, transformHandler),
structureLayer,
+217 -99
View File
@@ -19,18 +19,43 @@ const questionMarkIcon = assetUrl("images/QuestionMarkIcon.svg");
const targetIcon = assetUrl("images/TargetIcon.svg");
const traitorIcon = assetUrl("images/TraitorIcon.svg");
export type PlayerIconId =
| "crown"
| "traitor"
| "disconnected"
| "alliance"
| "alliance-request"
| "target"
| "emoji"
| "embargo"
| "nuke";
let allianceIconTemplate: HTMLDivElement | undefined;
export type PlayerIconKind = "image" | "emoji";
export const ALLIANCE_ICON_ID = "alliance" as const;
const ALLIANCE_PROGRESS_OVERLAY_CLASS = "alliance-progress-overlay";
const ALLIANCE_QUESTION_MARK_CLASS = "alliance-question-mark";
export const TRAITOR_ICON_ID = "traitor" as const;
const CROWN_ICON_ID = "crown" as const;
const DISCONNECTED_ICON_ID = "disconnected" as const;
const ALLIANCE_REQUEST_ICON_ID = "alliance-request" as const;
const TARGET_ICON_ID = "target" as const;
const EMOJI_ICON_ID = "emoji" as const;
const EMBARGO_ICON_ID = "embargo" as const;
const NUKE_ICON_ID = "nuke" as const;
export const IMAGE_ICON_KIND = "image" as const;
export const EMOJI_ICON_KIND = "emoji" as const;
export type PlayerIconId =
| typeof CROWN_ICON_ID
| typeof TRAITOR_ICON_ID
| typeof DISCONNECTED_ICON_ID
| typeof ALLIANCE_ICON_ID
| typeof ALLIANCE_REQUEST_ICON_ID
| typeof TARGET_ICON_ID
| typeof EMOJI_ICON_ID
| typeof EMBARGO_ICON_ID
| typeof NUKE_ICON_ID;
export type PlayerIconKind = typeof IMAGE_ICON_KIND | typeof EMOJI_ICON_KIND;
export type AllianceProgressIconRefs = {
wrapper: HTMLDivElement;
base: HTMLImageElement;
overlay: HTMLDivElement;
colored: HTMLImageElement;
questionMark: HTMLImageElement;
};
export interface PlayerIconDescriptor {
id: PlayerIconId;
@@ -50,6 +75,9 @@ export interface PlayerIconParams {
includeAllianceIcon: boolean;
/** Player currently in first place, used for the crown icon */
firstPlace: PlayerView | null;
alliancesDisabled: boolean;
darkMode?: boolean;
transitiveTargets?: PlayerView[];
}
export function getFirstPlacePlayer(game: GameView): PlayerView | null {
@@ -63,71 +91,99 @@ export function getFirstPlacePlayer(game: GameView): PlayerView | null {
export function getPlayerIcons(
params: PlayerIconParams,
): PlayerIconDescriptor[] {
const { game, player, includeAllianceIcon, firstPlace } = params;
const {
game,
player,
includeAllianceIcon,
firstPlace,
alliancesDisabled,
darkMode,
transitiveTargets,
} = params;
const myPlayer = game.myPlayer();
const userSettings = game.config().userSettings();
const isDarkMode = userSettings?.darkMode() ?? false;
const isDarkMode = darkMode ?? userSettings?.darkMode() ?? false;
const emojisEnabled = userSettings?.emojis() ?? false;
const alliancesOff = alliancesDisabled ?? game.config().disableAlliances();
const icons: PlayerIconDescriptor[] = [];
// Crown icon for first place
if (player === firstPlace) {
icons.push({ id: "crown", kind: "image", src: crownIcon });
icons.push({ id: CROWN_ICON_ID, kind: IMAGE_ICON_KIND, src: crownIcon });
}
// Traitor icon
if (player.isTraitor()) {
icons.push({ id: "traitor", kind: "image", src: traitorIcon });
icons.push({
id: TRAITOR_ICON_ID,
kind: IMAGE_ICON_KIND,
src: traitorIcon,
});
}
// Disconnected icon
if (player.isDisconnected()) {
icons.push({ id: "disconnected", kind: "image", src: disconnectedIcon });
}
// Alliance icon
if (
includeAllianceIcon &&
myPlayer !== null &&
myPlayer.isAlliedWith(player)
) {
icons.push({ id: "alliance", kind: "image", src: allianceIcon });
}
// Alliance request icon (theme dependent)
if (myPlayer !== null && player.isRequestingAllianceWith(myPlayer)) {
const allianceRequestIcon = isDarkMode
? allianceRequestWhiteIcon
: allianceRequestBlackIcon;
icons.push({
id: "alliance-request",
kind: "image",
src: allianceRequestIcon,
id: DISCONNECTED_ICON_ID,
kind: IMAGE_ICON_KIND,
src: disconnectedIcon,
});
}
if (!alliancesOff) {
// Alliance icon
if (
includeAllianceIcon &&
myPlayer !== null &&
myPlayer.isAlliedWith(player)
) {
icons.push({
id: ALLIANCE_ICON_ID,
kind: IMAGE_ICON_KIND,
src: allianceIcon,
});
}
// Alliance request icon (theme dependent)
if (myPlayer !== null && player.isRequestingAllianceWith(myPlayer)) {
const allianceRequestIcon = isDarkMode
? allianceRequestWhiteIcon
: allianceRequestBlackIcon;
icons.push({
id: ALLIANCE_REQUEST_ICON_ID,
kind: IMAGE_ICON_KIND,
src: allianceRequestIcon,
});
}
}
// Target icon (centered on the map, but regular in overlays)
if (myPlayer !== null && new Set(myPlayer.transitiveTargets()).has(player)) {
icons.push({ id: "target", kind: "image", src: targetIcon, center: true });
const targets = transitiveTargets ?? myPlayer?.transitiveTargets() ?? [];
if (targets.includes(player)) {
icons.push({
id: TARGET_ICON_ID,
kind: IMAGE_ICON_KIND,
src: targetIcon,
center: true,
});
}
// Emoji handling
if (emojisEnabled) {
const emojis = player
const emoji = player
.outgoingEmojis()
.filter(
(emoji) =>
emoji.recipientID === AllPlayers ||
emoji.recipientID === myPlayer?.smallID(),
.find(
(e) =>
e.recipientID === AllPlayers || e.recipientID === myPlayer?.smallID(),
);
if (emojis.length > 0) {
if (emoji) {
icons.push({
id: "emoji",
kind: "emoji",
text: emojis[0].message,
id: EMOJI_ICON_ID,
kind: EMOJI_ICON_KIND,
text: emoji.message,
});
}
}
@@ -135,91 +191,153 @@ export function getPlayerIcons(
// Embargo icon (theme dependent)
if (myPlayer?.hasEmbargo(player)) {
const embargoIcon = isDarkMode ? embargoWhiteIcon : embargoBlackIcon;
icons.push({ id: "embargo", kind: "image", src: embargoIcon });
icons.push({
id: EMBARGO_ICON_ID,
kind: IMAGE_ICON_KIND,
src: embargoIcon,
});
}
// Nuke icon (different color depending on whether the local player is the target)
const nukesSentByOtherPlayer = game.units(...Nukes.types).filter((unit) => {
const isSendingNuke = player.id() === unit.owner().id();
const notMyPlayer = !myPlayer || unit.owner().id() !== myPlayer.id();
return isSendingNuke && notMyPlayer && unit.isActive();
});
if (!myPlayer || player.id() !== myPlayer.id()) {
let hasActiveNukes = false;
let isMyPlayerTarget = false;
const playerNukes = player.units(...Nukes.types);
const isMyPlayerTarget = nukesSentByOtherPlayer.some((unit) => {
const detonationDst = unit.targetTile();
if (!detonationDst || !myPlayer) return false;
const targetId = game.owner(detonationDst).id();
return targetId === myPlayer.id();
});
for (const nuke of playerNukes) {
if (nuke.isActive()) {
hasActiveNukes = true;
if (nukesSentByOtherPlayer.length > 0) {
const icon = isMyPlayerTarget ? nukeRedIcon : nukeWhiteIcon;
icons.push({ id: "nuke", kind: "image", src: icon });
const detonationDst = nuke.targetTile();
if (
myPlayer &&
detonationDst &&
game.owner(detonationDst).id() === myPlayer.id()
) {
isMyPlayerTarget = true;
break;
}
}
}
if (hasActiveNukes) {
const icon = isMyPlayerTarget ? nukeRedIcon : nukeWhiteIcon;
icons.push({ id: NUKE_ICON_ID, kind: IMAGE_ICON_KIND, src: icon });
}
}
return icons;
}
export function createAllianceProgressIcon(
export function createAllianceProgressIconRefs(
size: number,
fraction: number,
hasExtensionRequest: boolean,
darkMode: boolean,
): HTMLDivElement {
darkMode: string,
): AllianceProgressIconRefs {
if (!allianceIconTemplate) {
allianceIconTemplate = document.createElement("div");
allianceIconTemplate.setAttribute("data-icon", ALLIANCE_ICON_ID);
allianceIconTemplate.style.position = "relative";
allianceIconTemplate.style.display = "inline-block";
allianceIconTemplate.style.flexShrink = "0";
const base = document.createElement("img");
base.src = allianceIconFaded;
base.style.display = "block";
allianceIconTemplate.appendChild(base);
const overlay = document.createElement("div");
overlay.className = ALLIANCE_PROGRESS_OVERLAY_CLASS;
overlay.style.position = "absolute";
overlay.style.left = "0";
overlay.style.top = "0";
overlay.style.width = "100%";
overlay.style.height = "100%";
const colored = document.createElement("img");
colored.src = allianceIcon; // green icon
colored.style.display = "block";
overlay.appendChild(colored);
allianceIconTemplate.appendChild(overlay);
const questionMark = document.createElement("img");
questionMark.className = ALLIANCE_QUESTION_MARK_CLASS;
questionMark.src = questionMarkIcon;
questionMark.style.position = "absolute";
questionMark.style.left = "0";
questionMark.style.top = "0";
questionMark.style.pointerEvents = "none";
allianceIconTemplate.appendChild(questionMark);
}
// Wrapper
const wrapper = document.createElement("div");
wrapper.setAttribute("data-icon", "alliance");
wrapper.setAttribute("dark-mode", darkMode.toString());
wrapper.style.position = "relative";
const wrapper = allianceIconTemplate.cloneNode(true) as HTMLDivElement;
wrapper.setAttribute("dark-mode", darkMode);
wrapper.style.width = `${size}px`;
wrapper.style.height = `${size}px`;
wrapper.style.display = "inline-block";
wrapper.style.flexShrink = "0";
// Base faded icon (full)
const base = document.createElement("img");
base.src = allianceIconFaded;
// No QuerySelector here since we know the structure and it avoids overhead each call
const base = wrapper.childNodes[0] as HTMLImageElement;
base.style.width = `${size}px`;
base.style.height = `${size}px`;
base.style.display = "block";
base.setAttribute("dark-mode", darkMode.toString());
wrapper.appendChild(base);
base.setAttribute("dark-mode", darkMode);
// Overlay container for green portion, clipped from the top via clip-path
const overlay = document.createElement("div");
overlay.className = "alliance-progress-overlay";
overlay.style.position = "absolute";
overlay.style.left = "0";
overlay.style.top = "0";
overlay.style.width = "100%";
overlay.style.height = "100%";
const overlay = wrapper.childNodes[1] as HTMLDivElement;
overlay.style.clipPath = computeAllianceClipPath(fraction);
const colored = document.createElement("img");
colored.src = allianceIcon; // green icon
const colored = overlay.childNodes[0] as HTMLImageElement;
colored.style.width = `${size}px`;
colored.style.height = `${size}px`;
colored.style.display = "block";
colored.setAttribute("dark-mode", darkMode.toString());
overlay.appendChild(colored);
wrapper.appendChild(overlay);
colored.setAttribute("dark-mode", darkMode);
// Question mark overlay (shown when there's a pending extension request)
const questionMark = document.createElement("img");
questionMark.className = "alliance-question-mark";
questionMark.src = questionMarkIcon;
questionMark.style.position = "absolute";
questionMark.style.left = "0";
questionMark.style.top = "0";
const questionMark = wrapper.childNodes[2] as HTMLImageElement;
questionMark.style.width = `${size}px`;
questionMark.style.height = `${size}px`;
questionMark.style.display = hasExtensionRequest ? "block" : "none";
questionMark.style.pointerEvents = "none";
questionMark.setAttribute("dark-mode", darkMode.toString());
wrapper.appendChild(questionMark);
questionMark.setAttribute("dark-mode", darkMode);
return wrapper;
return {
wrapper,
base,
overlay,
colored,
questionMark,
};
}
export function updateAllianceProgressIconRefs(
refs: AllianceProgressIconRefs,
size: number,
fraction: number,
hasExtensionRequest: boolean,
darkMode: string,
): void {
refs.wrapper.style.width = `${size}px`;
refs.wrapper.style.height = `${size}px`;
refs.wrapper.style.flexShrink = "0";
refs.base.style.width = `${size}px`;
refs.base.style.height = `${size}px`;
refs.base.setAttribute("dark-mode", darkMode);
refs.colored.style.width = `${size}px`;
refs.colored.style.height = `${size}px`;
refs.colored.setAttribute("dark-mode", darkMode);
refs.overlay.style.clipPath = computeAllianceClipPath(fraction);
if (!hasExtensionRequest) {
refs.questionMark.style.display = "none";
} else {
refs.questionMark.style.width = `${size}px`;
refs.questionMark.style.height = `${size}px`;
refs.questionMark.style.display = "block";
refs.questionMark.setAttribute("dark-mode", darkMode);
}
}
export function computeAllianceClipPath(fraction: number): string {
@@ -36,6 +36,7 @@ interface AttackLabel {
export class AttackingTroopsOverlay implements Layer {
private container: HTMLDivElement;
private labelTemplate: HTMLDivElement;
private labels = new Map<string, AttackLabel>();
// Guard against queuing multiple worker requests in the same tick window.
private inFlightRequest = false;
@@ -63,6 +64,8 @@ export class AttackingTroopsOverlay implements Layer {
this.container.style.zIndex = "4";
document.body.appendChild(this.container);
this.labelTemplate = this.createLabelTemplate();
this.onAlternateView = (e) => {
this.isVisible = !e.alternateView;
this.container.style.display = this.isVisible ? "" : "none";
@@ -235,28 +238,39 @@ export class AttackingTroopsOverlay implements Layer {
}
}
private createLabelElement(
attackerTroops: number,
defenderTroops: number,
isIncoming: boolean,
): HTMLDivElement {
private createLabelTemplate(): HTMLDivElement {
const el = document.createElement("div");
el.style.position = "absolute";
el.style.display = "none";
el.style.alignItems = "center";
el.style.gap = "3px";
el.style.width = "max-content";
el.style.whiteSpace = "nowrap";
el.style.fontSize = "11px";
el.style.fontWeight = "bold";
el.style.fontFamily = this.game.config().theme().font();
el.style.padding = "1px 4px";
el.style.borderRadius = "3px";
el.style.backgroundColor = "rgba(0,0,0,0.55)";
el.style.pointerEvents = "none";
el.style.lineHeight = "1.3";
// Smooth the label to its new position as the front line advances.
el.style.transition = "transform 0.2s ease-out";
el.style.width = "max-content";
const icon = document.createElement("img");
icon.style.width = "10px";
icon.style.height = "10px";
el.appendChild(icon);
const span = document.createElement("span");
span.style.minWidth = "25px";
el.appendChild(span);
return el;
}
private createLabelElement(
attackerTroops: number,
defenderTroops: number,
isIncoming: boolean,
): HTMLDivElement {
const el = this.labelTemplate.cloneNode(true) as HTMLDivElement;
el.style.fontFamily = this.game.config().theme().font();
this.updateLabelContent(el, attackerTroops, defenderTroops, isIncoming);
this.container.appendChild(el);
return el;
@@ -268,17 +282,8 @@ export class AttackingTroopsOverlay implements Layer {
defenderTroops: number,
isIncoming: boolean,
) {
// Reuse existing children to avoid DOM churn on every tick.
let icon = el.querySelector("img") as HTMLImageElement | null;
let span = el.querySelector("span") as HTMLSpanElement | null;
if (!icon || !span) {
icon = document.createElement("img");
icon.style.width = "10px";
icon.style.height = "10px";
span = document.createElement("span");
el.replaceChildren(icon, span);
}
const icon = el.children[0] as HTMLImageElement;
const span = el.children[1] as HTMLSpanElement;
if (isIncoming) {
icon.src = shieldIcon;
span.style.color = troopDefenceColor(attackerTroops, defenderTroops);
+4 -5
View File
@@ -4,6 +4,7 @@ import { assetUrl } from "../../../core/AssetUrls";
import { EventBus } from "../../../core/EventBus";
import { Gold } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import { ClientID } from "../../../core/Schemas";
import { AttackRatioEvent } from "../../InputHandler";
import { renderNumber, renderTroops } from "../../Utils";
@@ -50,9 +51,7 @@ export class ControlPanel extends LitElement implements Layer {
}
init() {
this.attackRatio = Number(
localStorage.getItem("settings.attackRatio") ?? "0.2",
);
this.attackRatio = new UserSettings().attackRatio();
this.uiState.attackRatio = this.attackRatio;
this.eventBus.on(AttackRatioEvent, (event) => {
let newAttackRatio = this.attackRatio + event.attackRatio / 100;
@@ -165,7 +164,7 @@ export class ControlPanel extends LitElement implements Layer {
: ""}
${orangePercent > 0
? html`<div
class="h-full bg-sky-600 transition-[width] duration-200"
class="h-full bg-[#0073b7] transition-[width] duration-200"
style="width: ${orangePercent}%;"
></div>`
: ""}
@@ -220,7 +219,7 @@ export class ControlPanel extends LitElement implements Layer {
: ""}
${orangePercent > 0
? html`<div
class="h-full bg-sky-600 transition-[width] duration-200"
class="h-full bg-[#0073b7] transition-[width] duration-200"
style="width: ${orangePercent}%;"
></div>`
: ""}
+27 -1
View File
@@ -36,6 +36,7 @@ import { onlyImages } from "../../../core/Util";
import { renderNumber } from "../../Utils";
import { GoToPlayerEvent, GoToUnitEvent } from "./Leaderboard";
import { PlaySoundEffectEvent } from "../../sound/Sounds";
import { getMessageTypeClasses, translateText } from "../../Utils";
import { UIState } from "../UIState";
const allianceIcon = assetUrl("images/AllianceIconWhite.svg");
@@ -189,7 +190,26 @@ export class EventsDisplay extends LitElement implements Layer {
this.events = [];
}
init() {}
init() {
this.eventBus.on(
SendAllianceRequestIntentEvent,
this.onAllianceRequestSentConfirmation.bind(this),
);
}
private onAllianceRequestSentConfirmation(e: SendAllianceRequestIntentEvent) {
const myPlayer = this.game.myPlayer();
if (!myPlayer || e.requestor.id() !== myPlayer.id()) {
return;
}
this.addEvent({
description: translateText("events_display.alliance_request_sent", {
name: e.recipient.name(),
}),
type: MessageType.ALLIANCE_REQUEST,
createdAt: this.game.ticks(),
});
}
tick() {
this.active = true;
@@ -444,6 +464,7 @@ export class EventsDisplay extends LitElement implements Layer {
type: MessageType.CHAT,
unsafeDescription: false,
});
this.eventBus.emit(new PlaySoundEffectEvent("message"));
}
onAllianceRequestEvent(update: AllianceRequestUpdate) {
@@ -459,6 +480,9 @@ export class EventsDisplay extends LitElement implements Layer {
update.recipientID,
) as PlayerView;
if (!requestor.isAlliedWith(recipient)) {
this.eventBus.emit(new PlaySoundEffectEvent("alliance-suggested"));
}
this.addEvent({
description: translateText("events_display.request_alliance", {
name: requestor.displayName(),
@@ -554,6 +578,7 @@ export class EventsDisplay extends LitElement implements Layer {
if (betrayed.isDisconnected()) return; // Do not send the message if betraying a disconnected player
if (!betrayed.isTraitor() && traitor === myPlayer) {
this.eventBus.emit(new PlaySoundEffectEvent("alliance-broken"));
const malusPercent = Math.round(
(1 - this.game.config().traitorDefenseDebuff()) * 100,
);
@@ -580,6 +605,7 @@ export class EventsDisplay extends LitElement implements Layer {
focusID: update.betrayedID,
});
} else if (betrayed === myPlayer) {
this.eventBus.emit(new PlaySoundEffectEvent("alliance-broken"));
const buttons = [
{
text: translateText("events_display.focus"),
+105 -41
View File
@@ -4,7 +4,7 @@ import { UnitType } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { ConquestUpdate, GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, UnitView } from "../../../core/game/GameView";
import SoundManager, { SoundEffect } from "../../sound/SoundManager";
import { PlaySoundEffectEvent } from "../../sound/Sounds";
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
import { conquestFxFactory } from "../fx/ConquestFx";
import { Fx, FxType } from "../fx/Fx";
@@ -26,6 +26,7 @@ export class FxLayer implements Layer {
private allFx: Fx[] = [];
private hasBufferedFrame = false;
private constructionState: Map<number, boolean> = new Map();
constructor(
private game: GameView,
@@ -39,10 +40,11 @@ export class FxLayer implements Layer {
return true;
}
private fxEnabled(): boolean {
return this.game.config().userSettings()?.fxLayer() ?? true;
}
tick() {
if (!this.game.config().userSettings()?.fxLayer()) {
return;
}
this.game
.updatesSinceLastTick()
?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id))
@@ -59,6 +61,11 @@ export class FxLayer implements Layer {
}
onUnitEvent(unit: UnitView) {
// Detect unit creation (launches, warship built)
if (unit.isActive() && unit.createdAt() === this.game.ticks()) {
this.onUnitCreated(unit);
}
switch (unit.type()) {
case UnitType.AtomBomb: {
this.onNukeEvent(unit, 70);
@@ -91,9 +98,28 @@ export class FxLayer implements Layer {
}
}
onUnitCreated(unit: UnitView) {
switch (unit.type()) {
case UnitType.AtomBomb:
this.eventBus.emit(new PlaySoundEffectEvent("atom-launch"));
break;
case UnitType.HydrogenBomb:
this.eventBus.emit(new PlaySoundEffectEvent("hydrogen-launch"));
break;
case UnitType.MIRV:
this.eventBus.emit(new PlaySoundEffectEvent("mirv-launch"));
break;
case UnitType.Warship:
if (unit.owner() === this.game.myPlayer()) {
this.eventBus.emit(new PlaySoundEffectEvent("build-warship"));
}
break;
}
}
onShellEvent(unit: UnitView) {
if (!unit.isActive()) {
if (unit.reachedTarget()) {
if (unit.reachedTarget() && this.fxEnabled()) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const explosion = new SpriteFx(
@@ -109,7 +135,7 @@ export class FxLayer implements Layer {
onTrainEvent(unit: UnitView) {
if (!unit.isActive()) {
if (!unit.reachedTarget()) {
if (!unit.reachedTarget() && this.fxEnabled()) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const explosion = new SpriteFx(
@@ -124,6 +150,7 @@ export class FxLayer implements Layer {
}
onRailroadEvent(tile: TileRef) {
if (!this.fxEnabled()) return;
// No need for pseudorandom, this is fx
const chanceFx = Math.floor(Math.random() * 3);
if (chanceFx === 0) {
@@ -146,15 +173,17 @@ export class FxLayer implements Layer {
return;
}
SoundManager.playSoundEffect(SoundEffect.KaChing);
this.eventBus.emit(new PlaySoundEffectEvent("ka-ching"));
this.allFx.push(
conquestFxFactory(this.animatedSpriteLoader, conquest, this.game),
);
if (this.fxEnabled()) {
this.allFx.push(
conquestFxFactory(this.animatedSpriteLoader, conquest, this.game),
);
}
}
onWarshipEvent(unit: UnitView) {
if (!unit.isActive()) {
if (!unit.isActive() && this.fxEnabled()) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const shipExplosion = new UnitExplosionFx(
@@ -179,15 +208,43 @@ export class FxLayer implements Layer {
onStructureEvent(unit: UnitView) {
if (!unit.isActive()) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const explosion = new SpriteFx(
this.animatedSpriteLoader,
x,
y,
FxType.BuildingExplosion,
);
this.allFx.push(explosion);
if (this.fxEnabled()) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const explosion = new SpriteFx(
this.animatedSpriteLoader,
x,
y,
FxType.BuildingExplosion,
);
this.allFx.push(explosion);
}
this.constructionState.delete(unit.id());
} else {
const wasUnderConstruction = this.constructionState.get(unit.id());
this.constructionState.set(unit.id(), unit.isUnderConstruction());
if (wasUnderConstruction && !unit.isUnderConstruction()) {
if (unit.owner() === this.game.myPlayer()) {
this.onStructureBuilt(unit);
}
}
}
}
onStructureBuilt(unit: UnitView) {
switch (unit.type()) {
case UnitType.City:
this.eventBus.emit(new PlaySoundEffectEvent("build-city"));
break;
case UnitType.Port:
this.eventBus.emit(new PlaySoundEffectEvent("build-port"));
break;
case UnitType.DefensePost:
this.eventBus.emit(new PlaySoundEffectEvent("build-defense-post"));
break;
case UnitType.SAMLauncher:
this.eventBus.emit(new PlaySoundEffectEvent("sam-built"));
break;
}
}
@@ -203,30 +260,37 @@ export class FxLayer implements Layer {
}
handleNukeExplosion(unit: UnitView, radius: number) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const nukeFx = nukeFxFactory(
this.animatedSpriteLoader,
x,
y,
radius,
this.game,
);
this.allFx = this.allFx.concat(nukeFx);
if (this.fxEnabled()) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const nukeFx = nukeFxFactory(
this.animatedSpriteLoader,
x,
y,
radius,
this.game,
);
this.allFx = this.allFx.concat(nukeFx);
}
const sound =
unit.type() === UnitType.HydrogenBomb ? "hydrogen-hit" : "atom-hit";
this.eventBus.emit(new PlaySoundEffectEvent(sound));
}
handleSAMInterception(unit: UnitView) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const explosion = new SpriteFx(
this.animatedSpriteLoader,
x,
y,
FxType.SAMExplosion,
);
this.allFx.push(explosion);
const shockwave = new ShockwaveFx(x, y, 800, 40);
this.allFx.push(shockwave);
if (this.fxEnabled()) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const explosion = new SpriteFx(
this.animatedSpriteLoader,
x,
y,
FxType.SAMExplosion,
);
this.allFx.push(explosion);
const shockwave = new ShockwaveFx(x, y, 800, 40);
this.allFx.push(shockwave);
}
}
async init() {
@@ -164,6 +164,13 @@ export class GameLeftSidebar extends LitElement implements Layer {
</div>
`
: null}
${this.isLeaderboardShow || this.isTeamLeaderboardShow
? html`<span
class="ml-auto text-[10px] text-slate-500 select-all leading-none self-start"
title=${translateText("help_modal.game_id_tooltip")}
>${this.game?.gameID() ?? ""}</span
>`
: null}
</div>
${this.isPlayerTeamLabelVisible
? html`
+54 -2
View File
@@ -18,6 +18,8 @@ const FastForwardIconSolid = assetUrl("images/FastForwardIconSolidWhite.svg");
const pauseIcon = assetUrl("images/PauseIconWhite.svg");
const playIcon = assetUrl("images/PlayIconWhite.svg");
const settingsIcon = assetUrl("images/SettingIconWhite.svg");
const fullscreenIcon = assetUrl("images/FullscreenIconWhite.svg");
const exitFullscreenIcon = assetUrl("images/ExitFullscreenIconWhite.svg");
@customElement("game-right-sidebar")
export class GameRightSidebar extends LitElement implements Layer {
@@ -36,6 +38,9 @@ export class GameRightSidebar extends LitElement implements Layer {
@state()
private isPaused: boolean = false;
@state()
private isFullscreen: boolean = false;
@state()
private timer: number = 0;
@@ -80,6 +85,21 @@ export class GameRightSidebar extends LitElement implements Layer {
this.requestUpdate();
}
private onFullscreenChange = () => {
this.isFullscreen = !!document.fullscreenElement;
};
connectedCallback() {
super.connectedCallback();
document.addEventListener("fullscreenchange", this.onFullscreenChange);
this.onFullscreenChange();
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener("fullscreenchange", this.onFullscreenChange);
}
getTickIntervalMs() {
return 250;
}
@@ -99,7 +119,10 @@ export class GameRightSidebar extends LitElement implements Layer {
const elapsedSeconds = Math.floor(gameTicks / 10); // 10 ticks per second
if (this.game.inSpawnPhase()) {
this.timer = maxTimerValue !== undefined ? maxTimerValue * 60 : 0;
this.timer =
maxTimerValue !== null && maxTimerValue !== undefined
? maxTimerValue * 60
: 0;
return;
}
@@ -107,7 +130,7 @@ export class GameRightSidebar extends LitElement implements Layer {
return;
}
if (maxTimerValue !== undefined) {
if (maxTimerValue !== null && maxTimerValue !== undefined) {
this.timer = Math.max(0, maxTimerValue * 60 - elapsedSeconds);
} else {
this.timer = elapsedSeconds;
@@ -174,11 +197,24 @@ export class GameRightSidebar extends LitElement implements Layer {
);
}
private onFullscreenButtonClick() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch((err) => {
console.warn("Failed to enter fullscreen:", err);
});
} else {
document.exitFullscreen().catch((err) => {
console.warn("Failed to exit fullscreen:", err);
});
}
}
render() {
if (this.game === undefined) return html``;
const timerColor =
this.game.config().gameConfig().maxTimerValue !== undefined &&
this.game.config().gameConfig().maxTimerValue !== null &&
this.timer < 60
? "text-red-400"
: "";
@@ -200,6 +236,22 @@ export class GameRightSidebar extends LitElement implements Layer {
<img src=${settingsIcon} alt="settings" width="20" height="20" />
</div>
${document.fullscreenEnabled
? html`<div
class="cursor-pointer"
@click=${this.onFullscreenButtonClick}
>
<img
src=${this.isFullscreen ? exitFullscreenIcon : fullscreenIcon}
alt=${this.isFullscreen
? translateText("fullscreen.exit")
: translateText("fullscreen.enter")}
width="20"
height="20"
/>
</div>`
: ""}
<div class="cursor-pointer" @click=${this.onExitButtonClick}>
<img src=${exitIcon} alt="exit" width="20" height="20" />
</div>
+103 -52
View File
@@ -1,90 +1,118 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { GameView } from "../../../core/game/GameView";
import { crazyGamesSDK } from "../../CrazyGamesSDK";
import { Layer } from "./Layer";
const AD_TYPE = "standard_iab_left1";
const AD_CONTAINER_ID = "in-game-bottom-left-ad";
const BOTTOM_RAIL_TYPE = "bottom_rail";
const AD_TYPES = [
{ type: "standard_iab_left1", selectorId: "in-game-bottom-left-ad" },
{ type: "standard_iab_left3", selectorId: "in-game-bottom-left-ad3" },
{ type: "standard_iab_left4", selectorId: "in-game-bottom-left-ad4" },
];
@customElement("in-game-promo")
export class InGamePromo extends LitElement implements Layer {
public game: GameView;
private shouldShow: boolean = false;
private bottomRailActive: boolean = false;
private adsVisible: boolean = false;
private bottomRailDestroyed: boolean = false;
private cornerAdShown: boolean = false;
private adCheckInterval: ReturnType<typeof setTimeout> | null = null;
createRenderRoot() {
return this;
}
init() {
this.showBottomRail();
}
init() {}
tick() {
if (!this.game.inSpawnPhase()) {
if (this.bottomRailActive) {
if (!this.bottomRailDestroyed) {
this.bottomRailDestroyed = true;
this.destroyBottomRail();
}
if (!this.cornerAdShown) {
this.cornerAdShown = true;
console.log("[InGamePromo] Spawn phase ended, triggering showAd");
this.showAd();
}
}
}
private showBottomRail(): void {
if (!window.adsEnabled) return;
if (!this.game.inSpawnPhase()) return;
if (!window.ramp) {
console.warn("Playwire RAMP not available for bottom_rail ad");
return;
}
this.bottomRailActive = true;
try {
window.ramp.que.push(() => {
try {
window.ramp.spaAddAds([{ type: BOTTOM_RAIL_TYPE }]);
console.log("Bottom rail ad loaded during spawn phase");
} catch (e) {
console.error("Failed to add bottom_rail ad:", e);
}
});
} catch (error) {
console.error("Failed to load bottom_rail ad:", error);
}
}
private destroyBottomRail(): void {
if (!this.bottomRailActive) return;
this.bottomRailActive = false;
if (!window.ramp) return;
try {
window.ramp.spaAds({ ads: [], countPageview: false });
console.log("Bottom rail ad destroyed via spaAds after spawn phase");
window.ramp.destroyUnits("pw-oop-bottom_rail");
console.log("Bottom rail ad destroyed after spawn phase");
} catch (e) {
console.error("Error destroying bottom_rail ad:", e);
}
}
private showAd(): void {
if (!window.adsEnabled) return;
console.log(
`[InGamePromo] showAd called, isOnCrazyGames=${crazyGamesSDK.isOnCrazyGames()}`,
);
if (window.innerWidth < 1100) return;
if (window.innerHeight < 750) return;
if (crazyGamesSDK.isOnCrazyGames()) {
this.showCrazyGamesAd();
return;
}
if (!window.adsEnabled) return;
this.shouldShow = true;
this.requestUpdate();
this.updateComplete.then(() => {
this.loadAd();
this.checkForAds();
});
}
private showCrazyGamesAd(): void {
console.log(
`[InGamePromo] showCrazyGamesAd called, isReady=${crazyGamesSDK.isReady()}, width=${window.innerWidth}, height=${window.innerHeight}`,
);
if (!crazyGamesSDK.isReady()) {
console.log(
"[InGamePromo] CrazyGames SDK not ready, skipping in-game ad",
);
return;
}
this.requestUpdate();
this.updateComplete.then(() => {
console.log("[InGamePromo] DOM updated, calling createBottomLeftAd");
crazyGamesSDK.createBottomLeftAd();
});
}
private checkForAds(): void {
if (this.adCheckInterval) {
clearInterval(this.adCheckInterval);
}
this.adCheckInterval = setInterval(() => {
const hasAds = AD_TYPES.some(({ selectorId }) => {
const el = document.getElementById(selectorId);
return el && el.clientHeight > 50;
});
if (hasAds) {
this.adsVisible = true;
this.requestUpdate();
if (this.adCheckInterval) {
clearInterval(this.adCheckInterval);
this.adCheckInterval = null;
}
}
}, 1000);
}
private loadAd(): void {
if (!window.ramp) {
console.warn("Playwire RAMP not available for in-game ad");
@@ -94,34 +122,49 @@ export class InGamePromo extends LitElement implements Layer {
try {
window.ramp.que.push(() => {
try {
window.ramp.spaAddAds([
{
type: AD_TYPE,
selectorId: AD_CONTAINER_ID,
},
]);
console.log("In-game bottom-left ad loaded:", AD_TYPE);
window.ramp.spaAddAds(
AD_TYPES.map(({ type, selectorId }) => ({ type, selectorId })),
);
console.log(
"In-game bottom-left ads loaded:",
AD_TYPES.map((a) => a.type),
);
} catch (e) {
console.error("Failed to add in-game ad:", e);
console.error("Failed to add in-game ads:", e);
}
});
} catch (error) {
console.error("Failed to load in-game ad:", error);
console.error("Failed to load in-game ads:", error);
}
}
public hideAd(): void {
if (this.adCheckInterval) {
clearInterval(this.adCheckInterval);
this.adCheckInterval = null;
}
this.adsVisible = false;
this.destroyBottomRail();
if (crazyGamesSDK.isOnCrazyGames()) {
crazyGamesSDK.clearBottomLeftAd();
this.shouldShow = false;
this.requestUpdate();
return;
}
if (!window.ramp) {
console.warn("Playwire RAMP not available for in-game ad");
return;
}
this.shouldShow = false;
try {
window.ramp.destroyUnits(AD_TYPE);
console.log("successfully destroyed in-game bottom-left ad");
for (const { type } of AD_TYPES) {
window.ramp.destroyUnits(type);
}
console.log("successfully destroyed in-game bottom-left ads");
} catch (e) {
console.error("error destroying in-game ad:", e);
console.error("error destroying in-game ads:", e);
}
this.requestUpdate();
}
@@ -137,10 +180,18 @@ export class InGamePromo extends LitElement implements Layer {
return html`
<div
id="${AD_CONTAINER_ID}"
class="fixed left-0 z-[100] pointer-events-auto"
id="in-game-promo-container"
class="fixed left-0 z-[100] pointer-events-auto flex flex-col-reverse ${this
.adsVisible
? "bg-gray-800 rounded-tr-lg p-1"
: ""}"
style="bottom: -0.7cm"
></div>
>
${AD_TYPES.map(
({ selectorId }) =>
html`<div id="${selectorId}" style="margin:0;padding:0"></div>`,
)}
</div>
`;
}
}
+302 -234
View File
@@ -1,19 +1,24 @@
import { assetUrl } from "../../../core/AssetUrls";
import { renderPlayerFlag } from "../../../core/CustomFlag";
import { assetUrl } from "src/core/AssetUrls";
import { EventBus } from "../../../core/EventBus";
import { PseudoRandom } from "../../../core/PseudoRandom";
import { Theme } from "../../../core/configuration/Config";
import { Config, Theme } from "../../../core/configuration/Config";
import { Cell } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import { AlternateViewEvent } from "../../InputHandler";
import { renderTroops } from "../../Utils";
import {
computeAllianceClipPath,
createAllianceProgressIcon,
ALLIANCE_ICON_ID,
AllianceProgressIconRefs,
createAllianceProgressIconRefs,
EMOJI_ICON_KIND,
getFirstPlacePlayer,
getPlayerIcons,
IMAGE_ICON_KIND,
PlayerIconDescriptor,
PlayerIconId,
TRAITOR_ICON_ID,
updateAllianceProgressIconRefs,
} from "../PlayerIcons";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
@@ -25,13 +30,8 @@ const PLAYER_ICONS = "player-icons";
const PLAYER_FLAG = "player-flag";
class RenderInfo {
public icons: Map<PlayerIconId, HTMLElement> = new Map(); // Track icon elements
public nameDiv: HTMLDivElement;
public nameSpan: HTMLSpanElement | null;
public troopsDiv: HTMLDivElement;
public flagDiv: HTMLDivElement | null;
public iconsDiv: HTMLDivElement;
public icons: Map<PlayerIconId, HTMLElement> = new Map();
public allianceIconRefs: AllianceProgressIconRefs | null = null;
constructor(
public player: PlayerView,
@@ -40,23 +40,17 @@ class RenderInfo {
public fontSize: number,
public fontColor: string,
public element: HTMLElement,
) {
// Traverse the DOM once, upon creation
this.nameDiv = element.querySelector(`.${PLAYER_NAME}`) as HTMLDivElement;
this.nameSpan = element.querySelector(
`.${PLAYER_NAME_SPAN}`,
) as HTMLSpanElement | null;
this.troopsDiv = element.querySelector(
`.${PLAYER_TROOPS}`,
) as HTMLDivElement;
this.flagDiv = element.querySelector(
`.${PLAYER_FLAG}`,
) as HTMLDivElement | null;
this.iconsDiv = element.querySelector(`.${PLAYER_ICONS}`) as HTMLDivElement;
}
public nameDiv: HTMLDivElement,
public nameSpan: HTMLSpanElement,
public troopsDiv: HTMLDivElement,
public flagImg: HTMLImageElement,
public iconsDiv: HTMLDivElement,
public lastTransform: string = "",
) {}
}
export class NameLayer implements Layer {
private config: Config;
private lastChecked = 0;
private renderCheckRate = 100;
private renderRefreshRate = 500;
@@ -64,11 +58,18 @@ export class NameLayer implements Layer {
private renders: RenderInfo[] = [];
private seenPlayers: Set<PlayerView> = new Set();
private container: HTMLDivElement;
private theme: Theme = this.game.config().theme();
private theme: Theme;
private userSettings: UserSettings = new UserSettings();
private isVisible: boolean = true;
private firstPlace: PlayerView | null = null;
private allianceDuration: number;
private alliancesDisabled: boolean = false;
private myPlayer: PlayerView | null = null;
private lastContainerTransform: string = "";
private basePlayerTemplate: HTMLDivElement;
private iconTemplate: HTMLImageElement;
private iconCenterTemplate: HTMLImageElement;
private emojiTemplate: HTMLDivElement;
constructor(
private game: GameView,
@@ -80,9 +81,7 @@ export class NameLayer implements Layer {
return false;
}
redraw() {
this.theme = this.game.config().theme();
}
redraw() {} // not affected by Canvas/WebGL context loss as this layer is DOM-based
public init() {
this.container = document.createElement("div");
@@ -108,6 +107,27 @@ export class NameLayer implements Layer {
`;
this.container.appendChild(style);
this.myPlayer = this.game.myPlayer();
this.config = this.game.config();
this.theme = this.config.theme();
this.alliancesDisabled = this.config.disableAlliances();
this.allianceDuration = Math.max(1, this.config.allianceDuration());
this.basePlayerTemplate = this.createBasePlayerElement();
this.iconTemplate = document.createElement("img");
this.iconCenterTemplate = document.createElement("img");
this.iconCenterTemplate.style.position = "absolute";
this.iconCenterTemplate.style.top = "50%";
this.iconCenterTemplate.style.transform = "translateY(-50%)";
this.emojiTemplate = document.createElement("div");
this.emojiTemplate.style.position = "absolute";
this.emojiTemplate.style.top = "50%";
this.emojiTemplate.style.transform = "translateY(-50%)";
this.eventBus.on(AlternateViewEvent, (e) => this.onAlternateViewChange(e));
}
@@ -132,15 +152,15 @@ export class NameLayer implements Layer {
: false;
const maxZoomScale = 17;
if (
const display =
!this.isVisible ||
size < 7 ||
(this.transformHandler.scale > maxZoomScale && size > 100) ||
!isOnScreen
) {
render.element.style.display = "none";
} else {
render.element.style.display = "flex";
? "none"
: "flex";
if (render.element.style.display !== display) {
render.element.style.display = display;
}
}
@@ -156,16 +176,7 @@ export class NameLayer implements Layer {
if (player.isAlive()) {
if (!this.seenPlayers.has(player)) {
this.seenPlayers.add(player);
this.renders.push(
new RenderInfo(
player,
0,
null,
0,
"",
this.createPlayerElement(player),
),
);
this.renders.push(this.createPlayerElement(player));
}
}
}
@@ -188,19 +199,24 @@ export class NameLayer implements Layer {
const now = Date.now();
if (now > this.lastChecked + this.renderCheckRate) {
this.lastChecked = now;
this.myPlayer ??= this.game.myPlayer();
const transitiveTargets = this.myPlayer?.transitiveTargets() ?? [];
for (const render of this.renders) {
this.renderPlayerInfo(render);
this.renderPlayerInfo(render, transitiveTargets);
}
}
}
private createPlayerElement(player: PlayerView): HTMLDivElement {
private createBasePlayerElement(): HTMLDivElement {
const element = document.createElement("div");
element.style.position = "absolute";
element.style.display = "flex";
element.style.flexDirection = "column";
element.style.alignItems = "center";
element.style.gap = "0px";
// Start off invisible so it doesn't flash at 0,0
element.style.display = "none";
const iconsDiv = document.createElement("div");
iconsDiv.classList.add(PLAYER_ICONS);
@@ -213,30 +229,7 @@ export class NameLayer implements Layer {
element.appendChild(iconsDiv);
const nameDiv = document.createElement("div");
const applyFlagStyles = (element: HTMLElement): void => {
element.classList.add(PLAYER_FLAG);
element.style.opacity = "0.8";
element.style.zIndex = "1";
element.style.aspectRatio = "3/4";
};
if (player.cosmetics.flag) {
const flag = player.cosmetics.flag;
if (flag !== undefined && flag !== null && flag.startsWith("!")) {
const flagWrapper = document.createElement("div");
applyFlagStyles(flagWrapper);
renderPlayerFlag(flag, flagWrapper);
nameDiv.appendChild(flagWrapper);
} else if (flag !== undefined && flag !== null) {
const flagImg = document.createElement("img");
applyFlagStyles(flagImg);
flagImg.src = assetUrl(`flags/${flag}.svg`);
nameDiv.appendChild(flagImg);
}
}
nameDiv.classList.add(PLAYER_NAME);
nameDiv.style.color = this.theme.textColor(player);
nameDiv.style.fontFamily = this.theme.font();
nameDiv.style.whiteSpace = "nowrap";
nameDiv.style.textOverflow = "ellipsis";
nameDiv.style.zIndex = "3";
@@ -244,30 +237,75 @@ export class NameLayer implements Layer {
nameDiv.style.justifyContent = "flex-end";
nameDiv.style.alignItems = "center";
const flagImg = document.createElement("img");
flagImg.classList.add(PLAYER_FLAG);
flagImg.style.opacity = "0.8";
flagImg.style.zIndex = "1";
flagImg.style.objectFit = "contain";
flagImg.style.display = "none";
nameDiv.appendChild(flagImg);
const nameSpan = document.createElement("span");
nameSpan.className = PLAYER_NAME_SPAN;
nameSpan.textContent = player.displayName();
nameSpan.classList.add(PLAYER_NAME_SPAN);
nameDiv.appendChild(nameSpan);
element.appendChild(nameDiv);
const troopsDiv = document.createElement("div");
troopsDiv.classList.add(PLAYER_TROOPS);
troopsDiv.setAttribute("translate", "no");
troopsDiv.textContent = renderTroops(player.troops());
troopsDiv.style.color = this.theme.textColor(player);
troopsDiv.style.fontFamily = this.theme.font();
troopsDiv.style.zIndex = "3";
troopsDiv.style.marginTop = "-5%";
element.appendChild(troopsDiv);
// Start off invisible so it doesn't flash at 0,0
element.style.display = "none";
this.container.appendChild(element);
return element;
}
renderPlayerInfo(render: RenderInfo) {
private createPlayerElement(player: PlayerView): RenderInfo {
const element = this.basePlayerTemplate.cloneNode(true) as HTMLDivElement;
// Queryselector expensive but this runs only once per player and better maintainable
const nameDiv = element.querySelector(`.${PLAYER_NAME}`) as HTMLDivElement;
const nameSpan = element.querySelector(
`.${PLAYER_NAME_SPAN}`,
) as HTMLSpanElement;
const troopsDiv = element.querySelector(
`.${PLAYER_TROOPS}`,
) as HTMLDivElement;
const flagImg = element.querySelector(
`.${PLAYER_FLAG}`,
) as HTMLImageElement;
const iconsDiv = element.querySelector(
`.${PLAYER_ICONS}`,
) as HTMLDivElement;
const font = this.theme.font();
nameDiv.style.fontFamily = font;
const flag = player.cosmetics.flag;
if (flag) {
flagImg.src = assetUrl(flag);
flagImg.style.display = "block";
}
const renderInfo = new RenderInfo(
player,
0,
null,
0,
"",
element,
nameDiv,
nameSpan,
troopsDiv,
flagImg,
iconsDiv,
);
this.container.appendChild(element);
return renderInfo;
}
renderPlayerInfo(render: RenderInfo, transitiveTargets: PlayerView[]) {
if (!render.player.nameLocation()) {
return;
}
@@ -304,31 +342,49 @@ export class NameLayer implements Layer {
}
render.lastRenderCalc = now + this.rand.nextInt(0, 100);
// Update text sizes and opacity
// Update text sizes, content, color and opacity
render.fontSize = Math.max(4, Math.floor(baseSize * 0.4));
render.fontColor = this.theme.textColor(render.player);
const nameOpacityPercent = this.userSettings.playerNameOpacity();
const nameOpacity = nameOpacityPercent / 100;
const hideFlag = nameOpacityPercent === 0;
render.nameDiv.style.fontSize = `${render.fontSize}px`;
render.nameDiv.style.lineHeight = `${render.fontSize}px`;
render.nameDiv.style.color = render.fontColor;
if (render.nameSpan) {
render.nameSpan.textContent = render.player.displayName();
render.nameSpan.style.opacity = `${nameOpacity}`;
}
if (render.flagDiv) {
render.flagDiv.style.height = `${render.fontSize}px`;
render.flagDiv.style.display = hideFlag ? "none" : "";
}
render.flagImg.style.height = `${render.fontSize}px`;
render.flagImg.style.display = hideFlag
? "none"
: render.player.cosmetics.flag
? "block"
: "none";
render.troopsDiv.style.fontSize = `${render.fontSize}px`;
render.nameSpan.textContent = render.player.displayName();
render.nameSpan.style.opacity = `${nameOpacity}`;
render.troopsDiv.style.opacity = `${nameOpacity}`;
render.troopsDiv.style.color = render.fontColor;
render.troopsDiv.textContent = renderTroops(render.player.troops());
const fontColor = this.theme.textColor(render.player);
if (render.fontColor !== fontColor) {
render.fontColor = fontColor;
render.nameDiv.style.color = fontColor;
render.troopsDiv.style.color = fontColor;
}
render.troopsDiv.textContent = renderTroops(render.player.troops());
const fontColor = this.theme.textColor(render.player);
if (render.fontColor !== fontColor) {
render.fontColor = fontColor;
render.nameDiv.style.color = fontColor;
render.troopsDiv.style.color = fontColor;
}
// Handle icons
const iconSize = Math.min(render.fontSize * 1.5, 48);
const darkMode = this.userSettings.darkMode();
const darkModeStr = darkMode.toString();
// Compute which icons should be shown for this player using shared logic
const icons = getPlayerIcons({
@@ -336,6 +392,9 @@ export class NameLayer implements Layer {
player: render.player,
includeAllianceIcon: true,
firstPlace: this.firstPlace,
darkMode: darkMode,
alliancesDisabled: this.alliancesDisabled,
transitiveTargets: transitiveTargets,
});
// Build a set of desired icon IDs
@@ -344,163 +403,172 @@ export class NameLayer implements Layer {
// Remove any icons that are no longer needed
for (const [id, element] of render.icons) {
if (!desiredIconIds.has(id)) {
element.remove();
render.icons.delete(id);
if (id === ALLIANCE_ICON_ID) {
render.allianceIconRefs?.wrapper.remove();
render.allianceIconRefs = null;
render.icons.delete(ALLIANCE_ICON_ID);
} else {
element.remove();
render.icons.delete(id);
}
}
}
// Add or update icons that should be shown
for (const icon of icons) {
if (icon.kind === "emoji" && icon.text) {
let emojiDiv = render.icons.get(icon.id) as HTMLDivElement | undefined;
if (icon.kind === EMOJI_ICON_KIND && icon.text) {
this.handleEmojiIcon(render, icon, iconSize);
continue;
} else if (!(icon.kind === IMAGE_ICON_KIND && icon.src)) {
continue;
}
// Special handling for alliance icon with progress indicator
if (icon.id === ALLIANCE_ICON_ID) {
this.handleAllianceIcons(render, iconSize, darkModeStr);
continue; // Skip regular image handling
}
if (!emojiDiv) {
emojiDiv = document.createElement("div");
emojiDiv.style.position = "absolute";
emojiDiv.style.top = "50%";
emojiDiv.style.transform = "translateY(-50%)";
render.iconsDiv.appendChild(emojiDiv);
render.icons.set(icon.id, emojiDiv);
}
const imgElement = this.handleOtherIcons(
render,
icon,
iconSize,
darkModeStr,
);
emojiDiv.textContent = icon.text;
emojiDiv.style.fontSize = `${iconSize}px`;
} else if (icon.kind === "image" && icon.src) {
// Special handling for alliance icon with progress indicator
if (icon.id === "alliance") {
let allianceWrapper = render.icons.get(icon.id) as
| HTMLDivElement
| undefined;
const myPlayer = this.game.myPlayer();
const allianceView = myPlayer
?.alliances()
.find((a) => a.other === render.player.id());
let fraction = 0;
let hasExtensionRequest = false;
if (allianceView) {
const remaining = Math.max(
0,
allianceView.expiresAt - this.game.ticks(),
);
const duration = Math.max(1, this.game.config().allianceDuration());
fraction = Math.max(0, Math.min(1, remaining / duration));
hasExtensionRequest = allianceView.hasExtensionRequest;
}
if (!allianceWrapper) {
allianceWrapper = createAllianceProgressIcon(
iconSize,
fraction,
hasExtensionRequest,
this.userSettings.darkMode(),
);
render.iconsDiv.appendChild(allianceWrapper);
render.icons.set(icon.id, allianceWrapper);
} else {
// Update existing alliance icon
allianceWrapper.style.width = `${iconSize}px`;
allianceWrapper.style.height = `${iconSize}px`;
allianceWrapper.style.flexShrink = "0";
const overlay = allianceWrapper.querySelector(
".alliance-progress-overlay",
) as HTMLDivElement | null;
if (overlay) {
overlay.style.clipPath = computeAllianceClipPath(fraction);
}
const questionMark = allianceWrapper.querySelector(
".alliance-question-mark",
) as HTMLImageElement | null;
if (questionMark) {
questionMark.style.display = hasExtensionRequest
? "block"
: "none";
}
// Update inner image sizes
const imgs = allianceWrapper.getElementsByTagName("img");
for (const img of imgs) {
img.style.width = `${iconSize}px`;
img.style.height = `${iconSize}px`;
}
}
continue; // Skip regular image handling
}
let imgElement = render.icons.get(icon.id) as
| HTMLImageElement
| undefined;
if (!imgElement) {
imgElement = this.createIconElement(icon.src, iconSize, icon.center);
render.iconsDiv.appendChild(imgElement);
render.icons.set(icon.id, imgElement);
}
// Update src if it changed (e.g., nuke red/white or dark-mode icons)
if (imgElement.src !== icon.src) {
imgElement.src = icon.src;
}
imgElement.style.width = `${iconSize}px`;
imgElement.style.height = `${iconSize}px`;
// Traitor flashing - smooth speed increase starting at 15s
if (icon.id === "traitor") {
const remainingTicks = render.player.getTraitorRemainingTicks();
// Use precise seconds (not rounded) for smoother transitions, rounded to 0.5s intervals
const remainingSeconds = Math.round((remainingTicks / 10) * 2) / 2;
if (remainingSeconds <= 15) {
// Smooth transition: starts at 1s at 15 seconds, decreases to 0.2s at 0 seconds
// Using cubic ease-out for slower, more gradual acceleration
const clampedSeconds = Math.max(0, Math.min(15, remainingSeconds));
const normalizedTime = clampedSeconds / 15; // 0 to 1 (1 = 15s remaining, 0 = 0s remaining)
// Cubic ease-out: slower acceleration, smoother transition
const easedProgress = 1 - Math.pow(1 - normalizedTime, 3);
const maxDuration = 1.0; // Slow flash at 15 seconds
const minDuration = 0.2; // Fast flash at 0 seconds
const duration =
minDuration + (maxDuration - minDuration) * easedProgress;
const animationDuration = `${duration.toFixed(2)}s`;
imgElement.style.animation = `traitorFlash ${animationDuration} infinite`;
imgElement.style.animationTimingFunction = "ease-in-out";
} else {
// Don't flash if more than 15 seconds remaining
imgElement.style.animation = "none";
}
}
// Traitor flashing - smooth speed increase starting at 15s
if (icon.id === TRAITOR_ICON_ID) {
this.handleTraitorIconFlashing(render.player, imgElement);
}
}
// Position element with scale
// Even when positionChanged is false: Scale update otherwise sometimes only happens after seconds which looks buggy.
// Don't require nameLocation to be changed: Scale update otherwise sometimes only happens after seconds which looks buggy.
// Because of sometimes overlapping delays of 20 ticks for nameLocation() (largestClusterBoundingBox in PlayerExecution)
// and the 500ms renderRefreshRate in NameLayer.
// and the 500ms renderRefreshRate in here.
const scale = Math.min(baseSize * 0.25, 3);
render.element.style.transform = `translate(${newX}px, ${newY}px) translate(-50%, -50%) scale(${scale})`;
const transform = `translate(${newX}px, ${newY}px) translate(-50%, -50%) scale(${scale})`;
if (render.lastTransform !== transform) {
render.element.style.transform = transform;
render.lastTransform = transform;
}
}
private createIconElement(
src: string,
private handleEmojiIcon(
render: RenderInfo,
icon: PlayerIconDescriptor,
size: number,
center: boolean = false,
): HTMLImageElement {
const icon = document.createElement("img");
icon.src = src;
icon.style.width = `${size}px`;
icon.style.height = `${size}px`;
icon.setAttribute("dark-mode", this.userSettings.darkMode().toString());
if (center) {
icon.style.position = "absolute";
icon.style.top = "50%";
icon.style.transform = "translateY(-50%)";
) {
let emojiDiv = render.icons.get(icon.id) as HTMLDivElement | undefined;
if (!emojiDiv) {
emojiDiv = this.emojiTemplate.cloneNode(true) as HTMLDivElement;
render.iconsDiv.appendChild(emojiDiv);
render.icons.set(icon.id, emojiDiv);
}
emojiDiv.textContent = icon.text ?? "";
emojiDiv.style.fontSize = `${size}px`;
}
private handleAllianceIcons(
render: RenderInfo,
size: number,
darkMode: string,
) {
this.myPlayer ??= this.game.myPlayer();
const allianceView = this.myPlayer
?.alliances()
.find((a) => a.other === render.player.id());
let fraction = 0;
let hasExtensionRequest = false;
if (allianceView) {
const remaining = Math.max(0, allianceView.expiresAt - this.game.ticks());
fraction = Math.max(0, Math.min(1, remaining / this.allianceDuration));
hasExtensionRequest = allianceView.hasExtensionRequest;
}
if (!render.allianceIconRefs) {
render.allianceIconRefs = createAllianceProgressIconRefs(
size,
fraction,
hasExtensionRequest,
darkMode,
);
render.iconsDiv.appendChild(render.allianceIconRefs.wrapper);
render.icons.set(ALLIANCE_ICON_ID, render.allianceIconRefs.wrapper);
} else {
updateAllianceProgressIconRefs(
render.allianceIconRefs,
size,
fraction,
hasExtensionRequest,
darkMode,
);
}
return;
}
private handleOtherIcons(
render: RenderInfo,
icon: PlayerIconDescriptor,
size: number,
darkMode: string,
): HTMLImageElement {
let imgElement = render.icons.get(icon.id) as HTMLImageElement | undefined;
if (!imgElement) {
imgElement = icon.center
? (this.iconCenterTemplate.cloneNode(true) as HTMLImageElement)
: (this.iconTemplate.cloneNode(true) as HTMLImageElement);
imgElement.src = icon.src ?? "";
imgElement.style.width = `${size}px`;
imgElement.style.height = `${size}px`;
imgElement.setAttribute("dark-mode", darkMode);
render.iconsDiv.appendChild(imgElement);
render.icons.set(icon.id, imgElement);
} else {
// Update src if it changed (e.g., nuke red/white or dark-mode icons)
if (imgElement.src !== icon.src) {
imgElement.src = icon.src ?? "";
}
imgElement.style.width = `${size}px`;
imgElement.style.height = `${size}px`;
imgElement.setAttribute("dark-mode", darkMode);
}
return imgElement;
}
private handleTraitorIconFlashing(
player: PlayerView,
icon: HTMLImageElement,
) {
const remainingTicks = player.getTraitorRemainingTicks();
// Use precise seconds (not rounded) for smoother transitions, rounded to 0.5s intervals
const remainingSeconds = Math.round((remainingTicks / 10) * 2) / 2;
if (remainingSeconds <= 15) {
// Smooth transition: starts at 1s at 15 seconds, decreases to 0.2s at 0 seconds
// Using cubic ease-out for slower, more gradual acceleration
const clampedSeconds = Math.max(0, Math.min(15, remainingSeconds));
const normalizedTime = clampedSeconds / 15; // 0 to 1 (1 = 15s remaining, 0 = 0s remaining)
// Cubic ease-out: slower acceleration, smoother transition
const easedProgress = 1 - Math.pow(1 - normalizedTime, 3);
const maxDuration = 1.0; // Slow flash at 15 seconds
const minDuration = 0.2; // Fast flash at 0 seconds
const duration =
minDuration + (maxDuration - minDuration) * easedProgress;
const animationDuration = `${duration.toFixed(2)}s`;
icon.style.animation = `traitorFlash ${animationDuration} infinite`;
icon.style.animationTimingFunction = "ease-in-out";
} else {
// Don't flash if more than 15 seconds remaining
icon.style.animation = "none";
}
return icon;
}
}
@@ -246,12 +246,6 @@ export class NukeTrajectoryPreviewLayer implements Layer {
game: this.game,
targetTile,
magnitude: this.game.config().nukeMagnitudes(ghostStructure),
allySmallIds: new Set(
this.game
.myPlayer()
?.allies()
.map((a) => a.smallID()),
),
threshold: this.game.config().nukeAllianceBreakThreshold(),
});
// Find the point where SAM can intercept
@@ -1,7 +1,11 @@
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { UserSettings } from "../../../core/game/UserSettings";
import {
PERFORMANCE_OVERLAY_KEY,
USER_SETTINGS_CHANGED_EVENT,
UserSettings,
} from "../../../core/game/UserSettings";
import {
TickMetricsEvent,
TogglePerformanceOverlayEvent,
@@ -469,21 +473,15 @@ export class PerformanceOverlay extends LitElement implements Layer {
) => {
const nextVisible = !this.isVisible;
this.setVisible(nextVisible);
this.userSettings.set("settings.performanceOverlay", nextVisible);
this.userSettings.setPerformanceOverlay(nextVisible);
};
private onTickMetricsEvent = (event: TickMetricsEvent) => {
this.updateTickMetrics(event.tickExecutionDuration, event.tickDelay);
};
private onUserSettingsChanged = (event: Event) => {
const customEvent = event as CustomEvent<{
key?: string;
value?: unknown;
}>;
if (customEvent.detail?.key !== "settings.performanceOverlay") return;
const nextVisible = customEvent.detail.value === true;
private onUserSettingsChanged = (event: CustomEvent<string>) => {
const nextVisible = event.detail === "true";
if (this.isVisible === nextVisible) return;
this.setVisible(nextVisible);
};
@@ -511,7 +509,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
if (!this.isUserSettingsListenerAttached) {
globalThis.addEventListener(
"user-settings-changed",
`${USER_SETTINGS_CHANGED_EVENT}:${PERFORMANCE_OVERLAY_KEY}`,
this.onUserSettingsChanged,
);
this.isUserSettingsListenerAttached = true;
@@ -523,7 +521,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
if (this.isUserSettingsListenerAttached) {
globalThis.removeEventListener(
"user-settings-changed",
`${USER_SETTINGS_CHANGED_EVENT}:${PERFORMANCE_OVERLAY_KEY}`,
this.onUserSettingsChanged,
);
this.isUserSettingsListenerAttached = false;
@@ -582,7 +580,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
private handleClose() {
const nextVisible = false;
this.setVisible(nextVisible);
this.userSettings.set("settings.performanceOverlay", nextVisible);
this.userSettings.setPerformanceOverlay(nextVisible);
}
private onDragPointerMove = (e: PointerEvent) => {
+15 -22
View File
@@ -1,8 +1,6 @@
import { LitElement, TemplateResult, html } from "lit";
import { ref } from "lit-html/directives/ref.js";
import { html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { assetUrl } from "../../../core/AssetUrls";
import { renderPlayerFlag } from "../../../core/CustomFlag";
import { EventBus } from "../../../core/EventBus";
import {
PlayerProfile,
@@ -26,7 +24,12 @@ import {
renderTroops,
translateText,
} from "../../Utils";
import { getFirstPlacePlayer, getPlayerIcons } from "../PlayerIcons";
import {
EMOJI_ICON_KIND,
getFirstPlacePlayer,
getPlayerIcons,
IMAGE_ICON_KIND,
} from "../PlayerIcons";
import { TransformHandler } from "../TransformHandler";
import { ImmunityBarVisibleEvent } from "./ImmunityTimer";
import { Layer } from "./Layer";
@@ -260,6 +263,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
// Because we already show the alliance icon next to the alliance expiration timer, we don't need to show it a second time in this render
includeAllianceIcon: false,
firstPlace,
alliancesDisabled: this.game.config().disableAlliances(),
});
if (icons.length === 0) {
@@ -268,11 +272,11 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
return html`<span class="flex items-center gap-1 ml-1 shrink-0">
${icons.map((icon) =>
icon.kind === "emoji" && icon.text
icon.kind === EMOJI_ICON_KIND && icon.text
? html`<span class="text-sm shrink-0" translate="no"
>${icon.text}</span
>`
: icon.kind === "image" && icon.src
: icon.kind === IMAGE_ICON_KIND && icon.src
? html`<img src=${icon.src} alt="" class="w-4 h-4 shrink-0" />`
: html``,
)}
@@ -365,21 +369,10 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
)}"
>
${player.cosmetics.flag
? player.cosmetics.flag!.startsWith("!")
? html`<div
class="h-6 aspect-3/4 player-flag"
${ref((el) => {
if (el instanceof HTMLElement) {
requestAnimationFrame(() => {
renderPlayerFlag(player.cosmetics.flag!, el);
});
}
})}
></div>`
: html`<img
class="h-6 aspect-3/4"
src=${assetUrl(`flags/${player.cosmetics.flag!}.svg`)}
/>`
? html`<img
class="h-6 object-contain"
src=${assetUrl(player.cosmetics.flag!)}
/>`
: html``}
<span>${player.displayName()}</span>
${playerTeam !== "" && player.type() !== PlayerType.Bot
@@ -452,7 +445,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
: ""}
${orangePercent > 0
? html`<div
class="h-full bg-sky-600 transition-[width] duration-200"
class="h-full bg-[#0073b7] transition-[width] duration-200"
style="width: ${orangePercent}%;"
></div>`
: ""}
@@ -18,6 +18,7 @@ export class PlayerModerationModal extends LitElement {
@property({ type: Boolean }) open: boolean = false;
@property({ type: Boolean }) alreadyKicked: boolean = false;
@property({ type: Boolean }) isAdmin: boolean = false;
createRenderRoot() {
return this;
@@ -44,7 +45,7 @@ export class PlayerModerationModal extends LitElement {
private canKick(my: PlayerView, other: PlayerView): boolean {
return (
my.isLobbyCreator() &&
(my.isLobbyCreator() || this.isAdmin) &&
other !== my &&
other.type() === PlayerType.Human &&
!!other.clientID()
+17 -3
View File
@@ -72,6 +72,15 @@ export class PlayerPanel extends LitElement implements Layer {
@state() private otherProfile: PlayerProfile | null = null;
@state() private suppressNextHide: boolean = false;
@state() private moderationTarget: PlayerView | null = null;
@state() private playerRole: string | null = null;
setRole(role: string | null): void {
this.playerRole = role;
}
private get isAdminRole(): boolean {
return this.playerRole === "admin" || this.playerRole === "root";
}
private ctModal: ChatModal;
@@ -441,8 +450,12 @@ export class PlayerPanel extends LitElement implements Layer {
`;
}
private renderModeration(my: PlayerView, other: PlayerView) {
if (!my.isLobbyCreator()) return html``;
private renderModeration(
my: PlayerView,
other: PlayerView,
isAdmin: boolean,
) {
if (!my.isLobbyCreator() && !isAdmin) return html``;
const moderationTitle = translateText("player_panel.moderation");
return html`
@@ -845,7 +858,7 @@ export class PlayerPanel extends LitElement implements Layer {
})}
</div>`
: ""}
${this.renderModeration(my, other)}
${this.renderModeration(my, other, this.isAdminRole)}
</div>
`;
}
@@ -963,6 +976,7 @@ export class PlayerPanel extends LitElement implements Layer {
.myPlayer=${my}
.target=${this.moderationTarget}
.eventBus=${this.eventBus}
.isAdmin=${this.isAdminRole}
.alreadyKicked=${this.kickedPlayerIDs.has(
String(this.moderationTarget.id()),
)}
+2
View File
@@ -2,6 +2,7 @@ import * as d3 from "d3";
import { assetUrl } from "../../../core/AssetUrls";
import { EventBus, GameEvent } from "../../../core/EventBus";
import { CloseViewEvent } from "../../InputHandler";
import { PlaySoundEffectEvent } from "../../sound/Sounds";
import { getSvgAspectRatio, translateText } from "../../Utils";
import { Layer } from "./Layer";
import {
@@ -506,6 +507,7 @@ export class RadialMenu implements Layer {
this.navigationInProgress
)
return;
this.eventBus.emit(new PlaySoundEffectEvent("click"));
if (
this.currentLevel > 0 &&
@@ -420,18 +420,19 @@ function createMenuElements(
: !BuildableAttacks.has(item.unitType)),
)
.map((item: BuildItemDisplay) => {
const canBuildOrUpgrade = params.buildMenu.canBuildOrUpgrade(item);
return {
id: `${elementIdPrefix}_${item.unitType}`,
name: item.key
? item.key.replace("unit_type.", "")
: item.unitType.toString(),
disabled: () => !canBuildOrUpgrade,
color: canBuildOrUpgrade
? filterType === "attack"
? COLORS.attack
: COLORS.building
: undefined,
disabled: (p: MenuElementParams) =>
!p.buildMenu.canBuildOrUpgrade(item),
color: (p: MenuElementParams) =>
p.buildMenu.canBuildOrUpgrade(item)
? filterType === "attack"
? COLORS.attack
: COLORS.building
: COLORS.building,
icon: item.icon,
tooltipItems: [
{ text: translateText(item.key ?? ""), className: "title" },
@@ -456,7 +457,7 @@ function createMenuElements(
if (buildableUnit === undefined) {
return;
}
if (canBuildOrUpgrade) {
if (params.buildMenu.canBuildOrUpgrade(item)) {
params.buildMenu.sendBuildOrUpgrade(buildableUnit, params.tile);
}
params.closeMenu();
+27 -6
View File
@@ -155,12 +155,30 @@ export class RailroadLayer implements Layer {
if (context === null) throw new Error("2d context not supported");
this.context = context;
// Firefox's GPU limit is 8192, only known browser issue
const maxTextureSize = 8192;
const scaleX = maxTextureSize / this.game.width();
const scaleY = maxTextureSize / this.game.height();
const targetScale = Math.min(2, scaleX, scaleY);
this.canvas.width = Math.max(
1,
Math.floor(this.game.width() * targetScale),
);
this.canvas.height = Math.max(
1,
Math.floor(this.game.height() * targetScale),
);
// Enable smooth scaling
this.context.imageSmoothingEnabled = true;
this.context.imageSmoothingQuality = "high";
this.canvas.width = this.game.width() * 2;
this.canvas.height = this.game.height() * 2;
// Scale context so existing *2 rendering math continues to work automatically
this.context.scale(
this.canvas.width / (this.game.width() * 2),
this.canvas.height / (this.game.height() * 2),
);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for (const [_, rail] of this.existingRailroads) {
@@ -215,10 +233,13 @@ export class RailroadLayer implements Layer {
return;
}
const srcX = visLeft * 2;
const srcY = visTop * 2;
const srcW = visWidth * 2;
const srcH = visHeight * 2;
const actualScaleX = this.canvas.width / this.game.width();
const actualScaleY = this.canvas.height / this.game.height();
const srcX = visLeft * actualScaleX;
const srcY = visTop * actualScaleY;
const srcW = visWidth * actualScaleX;
const srcH = visHeight * actualScaleY;
const dstX = -this.game.width() / 2 + visLeft;
const dstY = -this.game.height() / 2 + visTop;
@@ -106,7 +106,6 @@ export function computeDirection(
if (dx1 === 1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_LEFT;
if (dx1 === -1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_RIGHT;
}
console.warn(`Invalid rail segment: ${dx1}:${dy1}, ${dx2}:${dy2}`);
return RailType.VERTICAL;
}
+6 -7
View File
@@ -7,7 +7,10 @@ import { EventBus } from "../../../core/EventBus";
import { UserSettings } from "../../../core/game/UserSettings";
import { AlternateViewEvent, RefreshGraphicsEvent } from "../../InputHandler";
import { translateText } from "../../Utils";
import SoundManager from "../../sound/SoundManager";
import {
SetBackgroundMusicVolumeEvent,
SetSoundEffectsVolumeEvent,
} from "../../sound/Sounds";
import { Layer } from "./Layer";
const structureIcon = assetUrl("images/CityIconWhite.svg");
const cursorPriceIcon = assetUrl("images/CursorPriceIconWhite.svg");
@@ -52,10 +55,6 @@ export class SettingsModal extends LitElement implements Layer {
wasPausedWhenOpened = false;
init() {
SoundManager.setBackgroundMusicVolume(
this.userSettings.backgroundMusicVolume(),
);
SoundManager.setSoundEffectsVolume(this.userSettings.soundEffectsVolume());
this.eventBus.on(ShowSettingsModalEvent, (event) => {
this.isVisible = event.isVisible;
this.shouldPause = event.shouldPause;
@@ -183,14 +182,14 @@ export class SettingsModal extends LitElement implements Layer {
private onVolumeChange(event: Event) {
const volume = parseFloat((event.target as HTMLInputElement).value) / 100;
this.userSettings.setBackgroundMusicVolume(volume);
SoundManager.setBackgroundMusicVolume(volume);
this.eventBus.emit(new SetBackgroundMusicVolumeEvent(volume));
this.requestUpdate();
}
private onSoundEffectsVolumeChange(event: Event) {
const volume = parseFloat((event.target as HTMLInputElement).value) / 100;
this.userSettings.setSoundEffectsVolume(volume);
SoundManager.setSoundEffectsVolume(volume);
this.eventBus.emit(new SetSoundEffectsVolumeEvent(volume));
this.requestUpdate();
}
+18 -3
View File
@@ -130,12 +130,27 @@ export class StructureLayer implements Layer {
if (context === null) throw new Error("2d context not supported");
this.context = context;
// Firefox's GPU limit is 8192, only known browser issue
const maxTextureSize = 8192;
const scaleX = maxTextureSize / this.game.width();
const scaleY = maxTextureSize / this.game.height();
const targetScale = Math.min(2, scaleX, scaleY);
this.canvas.width = Math.max(
1,
Math.floor(this.game.width() * targetScale),
);
this.canvas.height = Math.max(
1,
Math.floor(this.game.height() * targetScale),
);
// Enable smooth scaling
this.context.imageSmoothingEnabled = true;
this.context.imageSmoothingQuality = "high";
this.canvas.width = this.game.width() * 2;
this.canvas.height = this.game.height() * 2;
this.context.scale(
this.canvas.width / (this.game.width() * 2),
this.canvas.height / (this.game.height() * 2),
);
Promise.all(
Array.from(this.unitIcons.values()).map((img) =>
+34 -4
View File
@@ -1,4 +1,4 @@
import { Theme } from "../../../core/configuration/Config";
import { Config, Theme } from "../../../core/configuration/Config";
import { GameView } from "../../../core/game/GameView";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
@@ -8,17 +8,47 @@ export class TerrainLayer implements Layer {
private context: CanvasRenderingContext2D;
private imageData: ImageData;
private theme: Theme;
private config: Config;
constructor(
private game: GameView,
private transformHandler: TransformHandler,
) {}
) {
this.config = this.game.config();
}
shouldTransform(): boolean {
return true;
}
tick() {
if (this.game.config().theme() !== this.theme) {
if (this.config.theme() !== this.theme) {
this.redraw();
return;
}
// Repaint terrain for tiles whose terrain changed (e.g. nuke
// turning land to water).
const updatedTiles = this.game.recentlyUpdatedTerrainTiles();
if (updatedTiles.length > 0) {
let dirty = false;
for (const tile of updatedTiles) {
const terrainColor = this.theme.terrainColor(this.game, tile);
const offset = tile * 4;
const r = terrainColor.rgba.r;
const g = terrainColor.rgba.g;
const b = terrainColor.rgba.b;
if (
this.imageData.data[offset] !== r ||
this.imageData.data[offset + 1] !== g ||
this.imageData.data[offset + 2] !== b
) {
this.imageData.data[offset] = r;
this.imageData.data[offset + 1] = g;
this.imageData.data[offset + 2] = b;
dirty = true;
}
}
if (dirty) {
this.context.putImageData(this.imageData, 0, 0);
}
}
}
@@ -46,7 +76,7 @@ export class TerrainLayer implements Layer {
}
initImageData() {
this.theme = this.game.config().theme();
this.theme = this.config.theme();
this.game.forEachTile((tile) => {
const terrainColor = this.theme.terrainColor(this.game, tile);
// TODO: isn't tileref and index the same?
+8 -5
View File
@@ -12,7 +12,6 @@ import {
import { euclDistFN, TileRef } from "../../../core/game/GameMap";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import { PseudoRandom } from "../../../core/PseudoRandom";
import {
AlternateViewEvent,
@@ -24,7 +23,6 @@ import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
export class TerritoryLayer implements Layer {
private userSettings: UserSettings;
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private imageData: ImageData;
@@ -62,9 +60,7 @@ export class TerritoryLayer implements Layer {
private game: GameView,
private eventBus: EventBus,
private transformHandler: TransformHandler,
userSettings: UserSettings,
) {
this.userSettings = userSettings;
this.theme = game.config().theme();
this.cachedTerritoryPatternsEnabled = undefined;
}
@@ -85,7 +81,14 @@ export class TerritoryLayer implements Layer {
this.spawnHighlight();
}
this.game.recentlyUpdatedTiles().forEach((t) => this.enqueueTile(t));
this.game.recentlyUpdatedTiles().forEach((t) => {
this.enqueueTile(t);
// Immediately clear territory overlay for water tiles so old
// borders/territory don't persist visually (e.g. after nuke turns land to water)
if (this.game.isWater(t)) {
this.clearTile(t);
}
});
const updates = this.game.updatesSinceLastTick();
const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : [];
unitUpdates.forEach((update) => {
+257 -61
View File
@@ -4,8 +4,13 @@ import { Theme } from "../../../core/configuration/Config";
import { UnitType } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, UnitView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import { UnitSelectionEvent } from "../../InputHandler";
import {
CloseViewEvent,
UnitSelectionEvent,
WarshipSelectionBoxCancelEvent,
WarshipSelectionBoxCompleteEvent,
WarshipSelectionBoxUpdateEvent,
} from "../../InputHandler";
import { ProgressBar } from "../ProgressBar";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
@@ -28,7 +33,6 @@ export class UILayer implements Layer {
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D | null;
private theme: Theme | null = null;
private userSettings: UserSettings = new UserSettings();
private selectionAnimTime = 0;
private allProgressBars: Map<
number,
@@ -38,6 +42,15 @@ export class UILayer implements Layer {
// Keep track of currently selected unit
private selectedUnit: UnitView | null = null;
// Keep track of multi-selected warships (box selection)
private multiSelectedWarships: UnitView[] = [];
// Per-unit last selection box position for multi-select cleanup
private multiSelectionBoxCenters: Map<
number,
{ x: number; y: number; size: number }
> = new Map();
// Keep track of previous selection box position for cleanup
private lastSelectionBoxCenter: {
x: number;
@@ -48,6 +61,16 @@ export class UILayer implements Layer {
// Visual settings for selection
private readonly SELECTION_BOX_SIZE = 6; // Size of the selection box (should be larger than the warship)
// Selection box (drag rectangle) state
private selectionBoxActive = false;
private selectionBoxStartX = 0;
private selectionBoxStartY = 0;
private selectionBoxEndX = 0;
private selectionBoxEndY = 0;
private selectionBoxCanvas: HTMLCanvasElement =
document.createElement("canvas");
private selectionBoxCtx: CanvasRenderingContext2D | null = null;
constructor(
private game: GameView,
private eventBus: EventBus,
@@ -69,6 +92,24 @@ export class UILayer implements Layer {
this.drawSelectionBox(this.selectedUnit);
}
// Animate multi-selected warships
for (const unit of this.multiSelectedWarships) {
if (unit.isActive()) {
this.drawSelectionBoxMulti(unit);
} else {
// Unit was destroyed — clean up its box
const prev = this.multiSelectionBoxCenters.get(unit.id());
if (prev) {
this.clearSelectionBox(prev.x, prev.y, prev.size);
this.multiSelectionBoxCenters.delete(unit.id());
}
}
}
// Remove destroyed units from the list
this.multiSelectedWarships = this.multiSelectedWarships.filter((u) =>
u.isActive(),
);
this.game
.updatesSinceLastTick()
?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id))
@@ -81,6 +122,25 @@ export class UILayer implements Layer {
init() {
this.eventBus.on(UnitSelectionEvent, (e) => this.onUnitSelection(e));
this.eventBus.on(WarshipSelectionBoxUpdateEvent, (e) => {
this.selectionBoxActive = true;
this.selectionBoxStartX = e.startX;
this.selectionBoxStartY = e.startY;
this.selectionBoxEndX = e.endX;
this.selectionBoxEndY = e.endY;
});
const clearBox = () => {
this.selectionBoxActive = false;
this.selectionBoxCtx?.clearRect(
0,
0,
this.selectionBoxCanvas.width,
this.selectionBoxCanvas.height,
);
};
this.eventBus.on(WarshipSelectionBoxCompleteEvent, clearBox);
this.eventBus.on(WarshipSelectionBoxCancelEvent, clearBox);
this.eventBus.on(CloseViewEvent, clearBox);
this.redraw();
}
@@ -92,14 +152,101 @@ export class UILayer implements Layer {
this.game.width(),
this.game.height(),
);
if (this.selectionBoxActive) {
this.renderSelectionBox(context);
}
}
private renderSelectionBox(context: CanvasRenderingContext2D) {
if (!this.selectionBoxCtx) return;
const topLeft = this.transformHandler.screenToWorldCoordinates(
Math.min(this.selectionBoxStartX, this.selectionBoxEndX),
Math.min(this.selectionBoxStartY, this.selectionBoxEndY),
);
const bottomRight = this.transformHandler.screenToWorldCoordinates(
Math.max(this.selectionBoxStartX, this.selectionBoxEndX),
Math.max(this.selectionBoxStartY, this.selectionBoxEndY),
);
const cx1 = Math.max(0, Math.floor(topLeft.x));
const cy1 = Math.max(0, Math.floor(topLeft.y));
const cx2 = Math.min(
this.selectionBoxCanvas.width - 1,
Math.floor(bottomRight.x),
);
const cy2 = Math.min(
this.selectionBoxCanvas.height - 1,
Math.floor(bottomRight.y),
);
if (cx2 <= cx1 || cy2 <= cy1) return;
const myPlayer = this.game.myPlayer();
const baseColor = myPlayer ? myPlayer.territoryColor().lighten(0.2) : null;
const colorStr = baseColor
? baseColor.alpha(0.85).toRgbString()
: "rgba(100,200,255,0.85)";
this.selectionBoxCtx.clearRect(
0,
0,
this.selectionBoxCanvas.width,
this.selectionBoxCanvas.height,
);
this.selectionBoxCtx.fillStyle = colorStr;
this.drawDashedLine(this.selectionBoxCtx, cx1, cy1, cx2, cy1);
this.drawDashedLine(this.selectionBoxCtx, cx1, cy2, cx2, cy2);
this.drawDashedLine(this.selectionBoxCtx, cx1, cy1, cx1, cy2);
this.drawDashedLine(this.selectionBoxCtx, cx2, cy1, cx2, cy2);
this.selectionBoxCtx.fillStyle = baseColor
? baseColor.alpha(0.06).toRgbString()
: "rgba(100,200,255,0.06)";
this.selectionBoxCtx.fillRect(
cx1 + 1,
cy1 + 1,
cx2 - cx1 - 1,
cy2 - cy1 - 1,
);
context.drawImage(
this.selectionBoxCanvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height(),
);
}
private drawDashedLine(
ctx: CanvasRenderingContext2D,
x1: number,
y1: number,
x2: number,
y2: number,
) {
if (x1 === x2) {
for (let y = y1; y <= y2; y++) {
if ((x1 + y) % 2 === 0) ctx.fillRect(x1, y, 1, 1);
}
} else {
for (let x = x1; x <= x2; x++) {
if ((x + y1) % 2 === 0) ctx.fillRect(x, y1, 1, 1);
}
}
}
redraw() {
this.canvas = document.createElement("canvas");
this.context = this.canvas.getContext("2d");
this.canvas.width = this.game.width();
this.canvas.height = this.game.height();
this.selectionBoxCanvas = document.createElement("canvas");
this.selectionBoxCanvas.width = this.game.width();
this.selectionBoxCanvas.height = this.game.height();
this.selectionBoxCtx = this.selectionBoxCanvas.getContext("2d");
}
onUnitEvent(unit: UnitView) {
@@ -153,23 +300,107 @@ export class UILayer implements Layer {
}
/**
* Handle the unit selection event
* Handle the unit selection event (single or multi).
* When event.units.length > 0 it's a multi-selection from box/select-all.
* When event.unit is set it's a single warship selection.
* When event.isSelected is false it clears all selection state.
*/
private onUnitSelection(event: UnitSelectionEvent) {
if (event.isSelected) {
this.selectedUnit = event.unit;
if (event.unit && event.unit.type() === UnitType.Warship) {
this.drawSelectionBox(event.unit);
// Always clear single-selection outline first
if (this.lastSelectionBoxCenter) {
const { x, y, size } = this.lastSelectionBoxCenter;
this.clearSelectionBox(x, y, size);
this.lastSelectionBoxCenter = null;
}
// selectedUnit is always reset regardless of lastSelectionBoxCenter
this.selectedUnit = null;
// Always clear previous multi-selection boxes
for (const [, center] of this.multiSelectionBoxCenters) {
this.clearSelectionBox(center.x, center.y, center.size);
}
this.multiSelectionBoxCenters.clear();
this.multiSelectedWarships = [];
if ((event.units ?? []).length > 0) {
// Multi-selection
this.multiSelectedWarships = event.units;
for (const unit of this.multiSelectedWarships) {
if (unit.isActive()) {
this.drawSelectionBoxMulti(unit);
}
}
} else {
// Single selection
this.selectedUnit = event.unit;
if (event.unit && event.unit.type() === UnitType.Warship) {
this.drawSelectionBox(event.unit);
}
}
} else {
if (this.selectedUnit === event.unit) {
// Clear the selection box
if (this.lastSelectionBoxCenter) {
const { x, y, size } = this.lastSelectionBoxCenter;
this.clearSelectionBox(x, y, size);
this.lastSelectionBoxCenter = null;
// Deselect everything
if (this.lastSelectionBoxCenter) {
const { x, y, size } = this.lastSelectionBoxCenter;
this.clearSelectionBox(x, y, size);
this.lastSelectionBoxCenter = null;
}
this.selectedUnit = null;
for (const [, center] of this.multiSelectionBoxCenters) {
this.clearSelectionBox(center.x, center.y, center.size);
}
this.multiSelectionBoxCenters.clear();
this.multiSelectedWarships = [];
}
}
/**
* Draw selection box for a multi-selected warship, tracking position per unit id.
*/
private drawSelectionBoxMulti(unit: UnitView) {
if (!unit || !unit.isActive()) return;
if (this.theme === null) throw new Error("missing theme");
const selectionColor = unit.owner().territoryColor().lighten(0.2);
const centerX = this.game.x(unit.tile());
const centerY = this.game.y(unit.tile());
const prev = this.multiSelectionBoxCenters.get(unit.id());
if (prev && (prev.x !== centerX || prev.y !== centerY)) {
this.clearSelectionBox(prev.x, prev.y, prev.size);
}
this.paintSelectionBoxAt(centerX, centerY, selectionColor);
this.multiSelectionBoxCenters.set(unit.id(), {
x: centerX,
y: centerY,
size: this.SELECTION_BOX_SIZE,
});
}
/**
* Shared helper: paint the dashed pulsing border pixels for a selection box.
*/
private paintSelectionBoxAt(
centerX: number,
centerY: number,
selectionColor: Colord,
) {
const size = this.SELECTION_BOX_SIZE;
const opacity = 200 + Math.sin(this.selectionAnimTime * 0.1) * 55;
for (let x = centerX - size; x <= centerX + size; x++) {
for (let y = centerY - size; y <= centerY + size; y++) {
if (
x === centerX - size ||
x === centerX + size ||
y === centerY - size ||
y === centerY + size
) {
if ((x + y) % 2 === 0) {
this.paintCell(x, y, selectionColor, opacity);
}
}
this.selectedUnit = null;
}
}
}
@@ -200,65 +431,30 @@ export class UILayer implements Layer {
return;
}
// Use the configured selection box size
const selectionSize = this.SELECTION_BOX_SIZE;
// Calculate pulsating effect based on animation time (25% variation in opacity)
const baseOpacity = 200;
const pulseAmount = 55;
const opacity =
baseOpacity + Math.sin(this.selectionAnimTime * 0.1) * pulseAmount;
// Get the unit's owner color for the box
if (this.theme === null) throw new Error("missing theme");
const ownerColor = unit.owner().territoryColor();
const selectionColor = unit.owner().territoryColor().lighten(0.2);
const centerX = this.game.x(unit.tile());
const centerY = this.game.y(unit.tile());
// Create a brighter version of the owner color for the selection
const selectionColor = ownerColor.lighten(0.2);
// Get current center position
const center = unit.tile();
const centerX = this.game.x(center);
const centerY = this.game.y(center);
// Clear previous selection box if it exists and is different from current position
// Clear previous box if unit moved
if (
this.lastSelectionBoxCenter &&
(this.lastSelectionBoxCenter.x !== centerX ||
this.lastSelectionBoxCenter.y !== centerY)
) {
const lastSize = this.lastSelectionBoxCenter.size;
const lastX = this.lastSelectionBoxCenter.x;
const lastY = this.lastSelectionBoxCenter.y;
// Clear the previous selection box
this.clearSelectionBox(lastX, lastY, lastSize);
this.clearSelectionBox(
this.lastSelectionBoxCenter.x,
this.lastSelectionBoxCenter.y,
this.lastSelectionBoxCenter.size,
);
}
// Draw the selection box
for (let x = centerX - selectionSize; x <= centerX + selectionSize; x++) {
for (let y = centerY - selectionSize; y <= centerY + selectionSize; y++) {
// Only draw if it's on the border (not inside or outside the box)
if (
x === centerX - selectionSize ||
x === centerX + selectionSize ||
y === centerY - selectionSize ||
y === centerY + selectionSize
) {
// Create a dashed effect by only drawing some pixels
const dashPattern = (x + y) % 2 === 0;
if (dashPattern) {
this.paintCell(x, y, selectionColor, opacity);
}
}
}
}
this.paintSelectionBoxAt(centerX, centerY, selectionColor);
// Store current selection box position for next cleanup
this.lastSelectionBoxCenter = {
x: centerX,
y: centerY,
size: selectionSize,
size: this.SELECTION_BOX_SIZE,
};
}
+11 -9
View File
@@ -10,6 +10,7 @@ import {
UnitType,
} from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import {
GhostStructureChangedEvent,
ToggleStructureEvent,
@@ -52,15 +53,9 @@ export class UnitDisplay extends LitElement implements Layer {
init() {
const config = this.game.config();
const userSettings = new UserSettings();
const savedKeybinds = localStorage.getItem("settings.keybinds");
if (savedKeybinds) {
try {
this.keybinds = JSON.parse(savedKeybinds);
} catch (e) {
console.warn("Invalid keybinds JSON:", e);
}
}
this.keybinds = userSettings.parsedUserKeybinds();
this.allDisabled = BuildMenus.types.every((u) => config.isUnitDisabled(u));
this.requestUpdate();
@@ -238,7 +233,7 @@ export class UnitDisplay extends LitElement implements Layer {
${hovered
? html`
<div
class="absolute -top-[250%] left-1/2 -translate-x-1/2 text-gray-200 text-center w-max text-xs bg-gray-800/90 backdrop-blur-xs rounded-sm p-1 z-[100] shadow-lg pointer-events-none"
class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 text-gray-200 text-center w-max text-xs bg-gray-800/90 backdrop-blur-xs rounded-sm p-1 z-[100] shadow-lg pointer-events-none"
>
<div class="font-bold text-sm mb-1">
${translateText(
@@ -248,6 +243,13 @@ export class UnitDisplay extends LitElement implements Layer {
<div class="p-2">
${translateText("build_menu.desc." + structureKey)}
</div>
${unitType === UnitType.Warship
? html`<div
class="mt-1 px-2 py-1 text-[10px] text-cyan-300 border-t border-white/10"
>
${translateText("build_menu.warship_shift_hint")}
</div>`
: null}
<div class="flex items-center justify-center gap-1">
<img src=${goldCoinIcon} width="13" height="13" />
<span class="text-yellow-300"
+107 -8
View File
@@ -1,16 +1,20 @@
import { colord, Colord } from "colord";
import { EventBus } from "../../../core/EventBus";
import { Theme } from "../../../core/configuration/Config";
import { UnitType } from "../../../core/game/Game";
import { Cell, UnitType } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, UnitView } from "../../../core/game/GameView";
import { BezenhamLine } from "../../../core/utilities/Line";
import {
AlternateViewEvent,
CloseViewEvent,
ContextMenuEvent,
MouseUpEvent,
SelectAllWarshipsEvent,
TouchEvent,
UnitSelectionEvent,
WarshipSelectionBoxCancelEvent,
WarshipSelectionBoxCompleteEvent,
} from "../../InputHandler";
import { MoveWarshipIntentEvent } from "../../Transport";
import { TransformHandler } from "../TransformHandler";
@@ -48,6 +52,9 @@ export class UnitLayer implements Layer {
// Selected unit property as suggested in the review comment
private selectedUnit: UnitView | null = null;
// Multi-selected warships (from selection box)
private selectedWarships: UnitView[] = [];
// Configuration for unit selection
private readonly WARSHIP_SELECTION_RADIUS = 10; // Radius in game cells for warship selection hit zone
@@ -93,6 +100,14 @@ export class UnitLayer implements Layer {
this.eventBus.on(MouseUpEvent, (e) => this.onMouseUp(e));
this.eventBus.on(TouchEvent, (e) => this.onTouch(e));
this.eventBus.on(UnitSelectionEvent, (e) => this.onUnitSelectionChange(e));
this.eventBus.on(WarshipSelectionBoxCompleteEvent, (e) =>
this.onSelectionBoxComplete(e),
);
this.eventBus.on(WarshipSelectionBoxCancelEvent, () =>
this.onSelectionBoxCancel(),
);
this.eventBus.on(CloseViewEvent, () => this.onSelectionBoxCancel());
this.eventBus.on(SelectAllWarshipsEvent, () => this.onSelectAllWarships());
this.redraw();
loadAllSprites();
@@ -137,11 +152,26 @@ export class UnitLayer implements Layer {
clickRef = this.game.ref(cell.x, cell.y);
}
if (!this.game.isOcean(clickRef)) return;
if (!this.game.isWater(clickRef)) return;
// If we have multi-selected warships, send them all to this tile
if (this.selectedWarships.length > 0) {
const myPlayer = this.game.myPlayer();
const activeIds = this.selectedWarships
.filter((u) => u.isActive() && u.owner() === myPlayer)
.map((u) => u.id());
if (activeIds.length > 0) {
this.eventBus.emit(new MoveWarshipIntentEvent(activeIds, clickRef));
}
this.selectedWarships = [];
this.eventBus.emit(new UnitSelectionEvent(null, false));
return;
}
if (this.selectedUnit) {
this.eventBus.emit(
new MoveWarshipIntentEvent(this.selectedUnit.id(), clickRef),
new MoveWarshipIntentEvent([this.selectedUnit.id()], clickRef),
);
// Deselect
this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false));
@@ -167,19 +197,28 @@ export class UnitLayer implements Layer {
}
const clickRef = this.game.ref(cell.x, cell.y);
if (!this.game.isOcean(clickRef)) {
// No isValidCoord/Ref check yet, that is done for ContextMenuEvent later
if (this.game.inSpawnPhase()) {
// No Radial Menu during spawn phase, only spawn point selection
if (!this.game.isWater(clickRef)) {
this.eventBus.emit(new MouseUpEvent(event.x, event.y));
}
return;
}
if (!this.game.isWater(clickRef)) {
// No warship to find because no Ocean tile, open Radial Menu
this.eventBus.emit(new ContextMenuEvent(event.x, event.y));
return;
}
if (!this.game.isValidRef(clickRef)) {
if (this.selectedUnit) {
// Reuse the mouse logic, send clickRef to avoid fetching it again
this.onMouseUp(new MouseUpEvent(event.x, event.y), clickRef);
return;
}
if (this.selectedUnit) {
// Reuse the mouse logic, send clickRef to avoid fetching it again
// Also delegate if we have multi-selected warships
if (this.selectedWarships.length > 0) {
this.onMouseUp(new MouseUpEvent(event.x, event.y), clickRef);
return;
}
@@ -209,6 +248,66 @@ export class UnitLayer implements Layer {
}
}
/**
* Handle completion of shift+drag selection box.
* Finds all player-owned warships within the screen rectangle.
*/
private onSelectionBoxComplete(event: WarshipSelectionBoxCompleteEvent) {
const x1 = Math.min(event.startX, event.endX);
const y1 = Math.min(event.startY, event.endY);
const x2 = Math.max(event.startX, event.endX);
const y2 = Math.max(event.startY, event.endY);
const myPlayer = this.game.myPlayer();
if (!myPlayer) return;
this.selectedWarships = this.game.units(UnitType.Warship).filter((unit) => {
if (!unit.isActive() || unit.owner() !== myPlayer) return false;
const screen = this.transformHandler.worldToScreenCoordinates(
new Cell(this.game.x(unit.tile()), this.game.y(unit.tile())),
);
return (
screen.x >= x1 && screen.x <= x2 && screen.y >= y1 && screen.y <= y2
);
});
// Clear single selection if we got a box selection
if (this.selectedWarships.length > 0 && this.selectedUnit) {
this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false));
}
// Notify UILayer to draw selection boxes for all selected warships
this.eventBus.emit(
new UnitSelectionEvent(null, true, this.selectedWarships),
);
}
private onSelectionBoxCancel() {
this.selectedWarships = [];
this.eventBus.emit(new UnitSelectionEvent(null, false));
}
private onSelectAllWarships() {
const myPlayer = this.game.myPlayer();
if (!myPlayer) return;
const allWarships = this.game
.units(UnitType.Warship)
.filter((u) => u.isActive() && u.owner() === myPlayer);
if (allWarships.length === 0) return;
// Clear single selection if active
if (this.selectedUnit) {
this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false));
}
this.selectedWarships = allWarships;
this.eventBus.emit(
new UnitSelectionEvent(null, true, this.selectedWarships),
);
}
/**
* Handle unit deactivation or destruction
* If the selected unit is removed from the game, deselect it
+17 -42
View File
@@ -6,17 +6,16 @@ import {
translateText,
TUTORIAL_VIDEO_URL,
} from "../../../client/Utils";
import { ColorPalette, Pattern } from "../../../core/CosmeticSchemas";
import { EventBus } from "../../../core/EventBus";
import { RankedType } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { getUserMe } from "../../Api";
import "../../components/PatternButton";
import "../../components/CosmeticButton";
import {
fetchCosmetics,
handlePurchase,
patternRelationship,
purchaseCosmetic,
resolveCosmetics,
} from "../../Cosmetics";
import { crazyGamesSDK } from "../../CrazyGamesSDK";
import { Platform } from "../../Platform";
@@ -62,7 +61,7 @@ export class WinModal extends LitElement implements Layer {
return html`
<div
class="${this.isVisible
? "fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-gray-800/70 p-6 shrink-0 rounded-lg z-9999 shadow-2xl backdrop-blur-xs text-white w-87.5 max-w-[90%] md:w-175"
? "fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-gray-800/70 p-6 shrink-0 rounded-lg z-[10010] shadow-2xl backdrop-blur-xs text-white w-87.5 max-w-[90%] md:w-175"
: "hidden"}"
>
<h2 class="m-0 mb-4 text-[26px] text-center text-white">
@@ -157,54 +156,30 @@ export class WinModal extends LitElement implements Layer {
async loadPatternContent() {
const me = await getUserMe();
const patterns = await fetchCosmetics();
const cosmetics = await fetchCosmetics();
const purchasablePatterns: {
pattern: Pattern;
colorPalette: ColorPalette;
}[] = [];
const purchasable = resolveCosmetics(cosmetics, me, null).filter(
(r) => r.type === "pattern" && r.relationship === "purchasable",
);
for (const pattern of Object.values(patterns?.patterns ?? {})) {
for (const colorPalette of pattern.colorPalettes ?? []) {
if (
patternRelationship(pattern, colorPalette, me, null) === "purchasable"
) {
const palette = patterns?.colorPalettes?.[colorPalette.name];
if (palette) {
purchasablePatterns.push({
pattern,
colorPalette: palette,
});
}
}
}
}
if (purchasablePatterns.length === 0) {
if (purchasable.length === 0) {
this.patternContent = html``;
return;
}
// Shuffle the array and take patterns based on screen size
const shuffled = [...purchasablePatterns].sort(() => Math.random() - 0.5);
const shuffled = [...purchasable].sort(() => Math.random() - 0.5);
const maxPatterns = Platform.isMobileWidth ? 1 : 3;
const selectedPatterns = shuffled.slice(
0,
Math.min(maxPatterns, shuffled.length),
);
const selected = shuffled.slice(0, Math.min(maxPatterns, shuffled.length));
this.patternContent = html`
<div class="flex gap-4 flex-wrap justify-start">
${selectedPatterns.map(
({ pattern, colorPalette }) => html`
<pattern-button
.pattern=${pattern}
.colorPalette=${colorPalette}
.requiresPurchase=${true}
.onSelect=${(p: Pattern | null) => {}}
.onPurchase=${(p: Pattern, colorPalette: ColorPalette | null) =>
handlePurchase(p, colorPalette)}
></pattern-button>
${selected.map(
(r) => html`
<cosmetic-button
.resolved=${r}
.onPurchase=${purchaseCosmetic}
></cosmetic-button>
`,
)}
</div>
+136 -67
View File
@@ -1,64 +1,118 @@
import { Howl } from "howler";
import of4 from "../../../proprietary/sounds/music/of4.mp3";
import openfront from "../../../proprietary/sounds/music/openfront.mp3";
import war from "../../../proprietary/sounds/music/war.mp3";
import { assetUrl } from "../../core/AssetUrls";
const kaChingSound = assetUrl("sounds/effects/ka-ching.mp3");
import { EventBus } from "../../core/EventBus";
import { UserSettings } from "../../core/game/UserSettings";
import {
PlaySoundEffectEvent,
SetBackgroundMusicVolumeEvent,
SetSoundEffectsVolumeEvent,
SoundEffect,
soundEffectUrls,
} from "./Sounds";
export enum SoundEffect {
KaChing = "ka-ching",
}
export const MAX_CONCURRENT_SOUNDS = 8;
class SoundManager {
export class SoundManager {
private backgroundMusic: Howl[] = [];
private currentTrack: number = 0;
private soundEffects: Map<SoundEffect, Howl> = new Map();
private soundEffectsVolume: number = 1;
private backgroundMusicVolume: number = 0;
private activeSounds: { howl: Howl; id: number }[] = [];
private eventBus: EventBus;
private onPlaySoundEffect: (e: PlaySoundEffectEvent) => void;
private onSetBackgroundMusicVolume: (
e: SetBackgroundMusicVolumeEvent,
) => void;
private onSetSoundEffectsVolume: (e: SetSoundEffectsVolumeEvent) => void;
constructor() {
this.backgroundMusic = [
new Howl({
src: [of4],
loop: false,
onend: this.playNext.bind(this),
volume: 0,
}),
new Howl({
src: [openfront],
loop: false,
onend: this.playNext.bind(this),
volume: 0,
}),
new Howl({
src: [war],
loop: false,
onend: this.playNext.bind(this),
volume: 0,
}),
];
this.loadSoundEffect(SoundEffect.KaChing, kaChingSound);
constructor(eventBus: EventBus, userSettings: UserSettings) {
this.eventBus = eventBus;
this.safely("initialize background music", () => {
this.backgroundMusic = [
new Howl({
src: [assetUrl("sounds/music/of4.mp3")],
loop: false,
onend: this.playNext.bind(this),
volume: 0,
}),
new Howl({
src: [assetUrl("sounds/music/openfront.mp3")],
loop: false,
onend: this.playNext.bind(this),
volume: 0,
}),
new Howl({
src: [assetUrl("sounds/music/war.mp3")],
loop: false,
onend: this.playNext.bind(this),
volume: 0,
}),
];
});
this.setBackgroundMusicVolume(userSettings.backgroundMusicVolume());
this.setSoundEffectsVolume(userSettings.soundEffectsVolume());
this.onPlaySoundEffect = (e) => this.playSoundEffect(e.effect);
this.onSetBackgroundMusicVolume = (e) =>
this.setBackgroundMusicVolume(e.volume);
this.onSetSoundEffectsVolume = (e) => this.setSoundEffectsVolume(e.volume);
eventBus.on(PlaySoundEffectEvent, this.onPlaySoundEffect);
eventBus.on(SetBackgroundMusicVolumeEvent, this.onSetBackgroundMusicVolume);
eventBus.on(SetSoundEffectsVolumeEvent, this.onSetSoundEffectsVolume);
}
public dispose(): void {
this.eventBus.off(PlaySoundEffectEvent, this.onPlaySoundEffect);
this.eventBus.off(
SetBackgroundMusicVolumeEvent,
this.onSetBackgroundMusicVolume,
);
this.eventBus.off(SetSoundEffectsVolumeEvent, this.onSetSoundEffectsVolume);
this.backgroundMusic.forEach((track) => {
this.safely("stop background track", () => track.stop());
this.safely("unload background track", () => track.unload());
});
this.soundEffects.forEach((sound) => {
this.safely("stop sound effect", () => sound.stop());
this.safely("unload sound effect", () => sound.unload());
});
this.soundEffects.clear();
this.activeSounds = [];
}
private safely(action: string, fn: () => void): void {
try {
fn();
} catch (err) {
console.error(`SoundManager: failed to ${action}`, err);
}
}
public playBackgroundMusic(): void {
if (
this.backgroundMusic.length > 0 &&
!this.backgroundMusic[this.currentTrack].playing()
) {
this.backgroundMusic[this.currentTrack].play();
}
this.safely("play background music", () => {
if (
this.backgroundMusic.length > 0 &&
!this.backgroundMusic[this.currentTrack].playing()
) {
this.backgroundMusic[this.currentTrack].play();
}
});
}
public stopBackgroundMusic(): void {
if (this.backgroundMusic.length > 0) {
this.backgroundMusic[this.currentTrack].stop();
}
this.safely("stop background music", () => {
if (this.backgroundMusic.length > 0) {
this.backgroundMusic[this.currentTrack].stop();
}
});
}
public setBackgroundMusicVolume(volume: number): void {
this.backgroundMusicVolume = Math.max(0, Math.min(1, volume));
this.backgroundMusic.forEach((track) => {
track.volume(this.backgroundMusicVolume);
this.safely("set background music volume", () => {
this.backgroundMusic.forEach((track) => {
track.volume(this.backgroundMusicVolume);
});
});
}
@@ -67,44 +121,59 @@ class SoundManager {
this.playBackgroundMusic();
}
public loadSoundEffect(name: SoundEffect, src: string): void {
if (!this.soundEffects.has(name)) {
const sound = new Howl({
src: [src],
volume: this.soundEffectsVolume,
});
private getOrLoadSoundEffect(name: SoundEffect): Howl | null {
let sound = this.soundEffects.get(name);
if (sound) return sound;
const src = soundEffectUrls.get(name);
if (!src) return null;
try {
sound = new Howl({ src: [src], volume: this.soundEffectsVolume });
this.soundEffects.set(name, sound);
return sound;
} catch (err) {
console.error(`SoundManager: failed to load sound ${name}`, err);
return null;
}
}
private removeActiveSoundById(id: number): void {
this.activeSounds = this.activeSounds.filter((s) => s.id !== id);
}
public playSoundEffect(name: SoundEffect): void {
const sound = this.soundEffects.get(name);
if (sound) {
sound.play();
}
this.safely(`play sound ${name}`, () => {
const howl = this.getOrLoadSoundEffect(name);
if (!howl) return;
if (this.activeSounds.length >= MAX_CONCURRENT_SOUNDS) {
const oldest = this.activeSounds[0];
oldest.howl.stop(oldest.id);
this.removeActiveSoundById(oldest.id);
}
const id = howl.play();
this.activeSounds.push({ howl, id });
howl.once("end", () => this.removeActiveSoundById(id), id);
howl.once("stop", () => this.removeActiveSoundById(id), id);
});
}
public setSoundEffectsVolume(volume: number): void {
this.soundEffectsVolume = Math.max(0, Math.min(1, volume));
this.soundEffects.forEach((sound) => {
sound.volume(this.soundEffectsVolume);
this.safely("set sound effects volume", () => {
this.soundEffects.forEach((sound) => {
sound.volume(this.soundEffectsVolume);
});
});
}
public stopSoundEffect(name: SoundEffect): void {
const sound = this.soundEffects.get(name);
if (sound) {
sound.stop();
}
}
public unloadSoundEffect(name: SoundEffect): void {
const sound = this.soundEffects.get(name);
if (sound) {
sound.unload();
this.soundEffects.delete(name);
}
this.safely(`stop sound ${name}`, () => {
const howl = this.soundEffects.get(name);
if (howl) {
howl.stop();
this.activeSounds = this.activeSounds.filter((s) => s.howl !== howl);
}
});
}
}
export default new SoundManager();
+49
View File
@@ -0,0 +1,49 @@
import { assetUrl } from "../../core/AssetUrls";
import { GameEvent } from "../../core/EventBus";
export type SoundEffect =
| "ka-ching"
| "atom-hit"
| "atom-launch"
| "hydrogen-hit"
| "hydrogen-launch"
| "mirv-launch"
| "alliance-suggested"
| "alliance-broken"
| "build-port"
| "build-city"
| "build-defense-post"
| "build-warship"
| "sam-built"
| "message"
| "click";
export const soundEffectUrls: ReadonlyMap<SoundEffect, string> = new Map([
["ka-ching", assetUrl("sounds/effects/ka-ching.mp3")],
["atom-hit", assetUrl("sounds/effects/atom-hit.mp3")],
["atom-launch", assetUrl("sounds/effects/atom-launch.mp3")],
["hydrogen-hit", assetUrl("sounds/effects/hydrogen-hit.mp3")],
["hydrogen-launch", assetUrl("sounds/effects/hydrogen-launch.mp3")],
["mirv-launch", assetUrl("sounds/effects/mirv-launch.mp3")],
["alliance-suggested", assetUrl("sounds/effects/alliance-suggested.mp3")],
["alliance-broken", assetUrl("sounds/effects/alliance-broken.mp3")],
["build-port", assetUrl("sounds/effects/build-port.mp3")],
["build-city", assetUrl("sounds/effects/build-city.mp3")],
["build-defense-post", assetUrl("sounds/effects/build-defense-post.mp3")],
["build-warship", assetUrl("sounds/effects/build-warship.mp3")],
["sam-built", assetUrl("sounds/effects/sam-built.mp3")],
["message", assetUrl("sounds/effects/message.mp3")],
["click", assetUrl("sounds/effects/click.mp3")],
]);
export class PlaySoundEffectEvent implements GameEvent {
constructor(public readonly effect: SoundEffect) {}
}
export class SetSoundEffectsVolumeEvent implements GameEvent {
constructor(public readonly volume: number) {}
}
export class SetBackgroundMusicVolumeEvent implements GameEvent {
constructor(public readonly volume: number) {}
}
+4 -4
View File
@@ -5,7 +5,7 @@
@theme {
--default-ring-width: 3px;
--default-ring-color: var(--color-blue-500);
--default-ring-color: var(text-[#0073b7]);
}
@layer base {
@@ -113,7 +113,7 @@ body {
padding: 15px 20px;
font-size: 16px;
cursor: pointer;
background-color: #007bff;
background-color: #0073b7;
color: white;
border: none;
border-radius: 8px;
@@ -123,7 +123,7 @@ body {
}
.start-game-button:not(:disabled):hover {
background-color: #0056b3;
background-color: #0073b7;
}
.start-game-button:disabled {
@@ -580,7 +580,7 @@ label.option-card:hover {
/* News Button Notification */
news-button .active button {
position: relative;
border-color: #2563eb !important;
border-color: #0073b7 !important;
border-width: 2px !important;
box-shadow:
0 0 0 1px rgba(37, 99, 235, 0.5),
+1 -1
View File
@@ -30,6 +30,6 @@
}
.l-header__highlightText {
color: #2563eb;
color: #0073b7;
font-weight: 700;
}
+39 -15
View File
@@ -43,9 +43,19 @@ export const TokenPayloadSchema = z.object({
iss: z.string(),
aud: z.string(),
exp: z.number(),
role: z
.enum(["root", "admin", "mod", "flagged", "banned"])
// In case new roles are added in the future.
.or(z.string())
.optional(),
});
export type TokenPayload = z.infer<typeof TokenPayloadSchema>;
export const ADMIN_ROLES = ["admin", "root"] as const;
export function isAdminRole(role: string | null | undefined): boolean {
return role === "admin" || role === "root";
}
export const DiscordUserSchema = z.object({
id: z.string(),
avatar: z.string().nullable(),
@@ -67,16 +77,10 @@ export const UserMeResponseSchema = z.object({
}),
player: z.object({
publicId: z.string(),
roles: z.string().array().optional(),
flares: z.string().array().optional(),
achievements: z
.array(
z.object({
type: z.literal("singleplayer-map"), // TODO: change the shape to be more flexible when we have more achievements
data: z.array(SingleplayerMapAchievementSchema),
}),
)
.optional(),
achievements: z.object({
singleplayerMap: z.array(SingleplayerMapAchievementSchema),
}),
leaderboard: z
.object({
oneVone: z
@@ -86,6 +90,12 @@ export const UserMeResponseSchema = z.object({
.optional(),
})
.optional(),
currency: z
.object({
soft: z.coerce.number(),
hard: z.coerce.number(),
})
.optional(),
}),
});
export type UserMeResponse = z.infer<typeof UserMeResponseSchema>;
@@ -98,13 +108,17 @@ export const PlayerStatsLeafSchema = z.object({
});
export type PlayerStatsLeaf = z.infer<typeof PlayerStatsLeafSchema>;
export const PlayerStatsTreeSchema = z.partialRecord(
z.enum(GameType),
z.partialRecord(
z.enum(GameMode),
z.partialRecord(z.enum(Difficulty), PlayerStatsLeafSchema),
),
const GameModeStatsSchema = z.partialRecord(
z.enum(GameMode),
z.partialRecord(z.enum(Difficulty), PlayerStatsLeafSchema),
);
export const PlayerStatsTreeSchema = z.object({
Singleplayer: GameModeStatsSchema.optional(),
Public: GameModeStatsSchema.optional(),
Private: GameModeStatsSchema.optional(),
Ranked: z.partialRecord(z.enum(RankedType), PlayerStatsLeafSchema).optional(),
});
export type PlayerStatsTree = z.infer<typeof PlayerStatsTreeSchema>;
export const PlayerGameSchema = z.object({
@@ -192,3 +206,13 @@ export const RankedLeaderboardResponseSchema = z.object({
export type RankedLeaderboardResponse = z.infer<
typeof RankedLeaderboardResponseSchema
>;
export const NewsItemSchema = z.object({
id: z.string(),
title: z.string(),
description: z.string().optional(),
descriptionTranslationKey: z.string().optional(),
url: z.string().nullable().optional(),
type: z.enum(["tournament", "tutorial", "announcement"]).or(z.string()),
});
export type NewsItem = z.infer<typeof NewsItemSchema>;
+8
View File
@@ -44,10 +44,18 @@ export function normalizeAssetPath(path: string): string {
return normalizedPath;
}
function isAbsoluteUrl(path: string): boolean {
return /^https?:\/\//i.test(path);
}
export function buildAssetUrl(
path: string,
assetManifest: AssetManifest = {},
): string {
if (isAbsoluteUrl(path)) {
return path;
}
const normalizedPath = normalizeAssetPath(path);
const directUrl = assetManifest[normalizedPath];
+29 -25
View File
@@ -5,7 +5,9 @@ import { PlayerPattern } from "./Schemas";
export type Cosmetics = z.infer<typeof CosmeticsSchema>;
export type Pattern = z.infer<typeof PatternSchema>;
export type PatternName = z.infer<typeof PatternNameSchema>;
export type Flag = z.infer<typeof FlagSchema>;
export type Pack = z.infer<typeof PackSchema>;
export type PatternName = z.infer<typeof CosmeticNameSchema>;
export type Product = z.infer<typeof ProductSchema>;
export type ColorPalette = z.infer<typeof ColorPaletteSchema>;
export type PatternData = z.infer<typeof PatternDataSchema>;
@@ -16,7 +18,7 @@ export const ProductSchema = z.object({
price: z.string(),
});
export const PatternNameSchema = z
export const CosmeticNameSchema = z
.string()
.regex(/^[a-z0-9_]+$/)
.max(32);
@@ -50,8 +52,19 @@ export const ColorPaletteSchema = z.object({
secondaryColor: z.string(),
});
export const PatternSchema = z.object({
name: PatternNameSchema,
const CosmeticSchema = z.object({
name: CosmeticNameSchema,
affiliateCode: z.string().nullable(),
product: ProductSchema.nullable(),
priceSoft: z.number().optional(),
priceHard: z.number().optional(),
artist: z.string().optional(),
rarity: z
.enum(["common", "uncommon", "rare", "epic", "legendary"])
.or(z.string()),
});
export const PatternSchema = CosmeticSchema.extend({
pattern: PatternDataSchema,
colorPalettes: z
.object({
@@ -60,33 +73,24 @@ export const PatternSchema = z.object({
})
.array()
.optional(),
affiliateCode: z.string().nullable(),
product: ProductSchema.nullable(),
});
export const FlagSchema = CosmeticSchema.extend({
url: z.string(),
});
export const PackSchema = CosmeticSchema.extend({
displayName: z.string(),
currency: z.enum(["hard", "soft"]),
amount: z.number().int().positive(),
});
// Schema for resources/cosmetics/cosmetics.json
export const CosmeticsSchema = z.object({
colorPalettes: z.record(z.string(), ColorPaletteSchema).optional(),
patterns: z.record(z.string(), PatternSchema),
flag: z
.object({
layers: z.record(
z.string(),
z.object({
name: z.string(),
flares: z.array(z.string()).optional(),
}),
),
color: z.record(
z.string(),
z.object({
color: z.string(),
name: z.string(),
flares: z.array(z.string()).optional(),
}),
),
})
.optional(),
flags: z.record(z.string(), FlagSchema),
currencyPacks: z.record(z.string(), PackSchema).optional(),
});
export const DefaultPattern = {
-81
View File
@@ -1,81 +0,0 @@
import { assetUrl } from "./AssetUrls";
import { Cosmetics } from "./CosmeticSchemas";
const ANIMATION_DURATIONS: Record<string, number> = {
rainbow: 4000,
"bright-rainbow": 4000,
"copper-glow": 3000,
"silver-glow": 3000,
"gold-glow": 3000,
neon: 3000,
lava: 6000,
water: 6200,
};
// TODO: Pass in cosmetics as a parameter when
// remote cosmetics are implemented for custom flags
export function renderPlayerFlag(
flag: string,
target: HTMLElement,
cosmetics: Cosmetics | undefined = undefined,
) {
if (cosmetics === undefined) {
console.warn("No cosmetics provided for flag", flag);
return;
}
if (!flag.startsWith("!")) return;
const code = flag.slice("!".length);
const layers = code.split("_").map((segment) => {
const [layerKey, colorKey] = segment.split("-");
return { layerKey, colorKey };
});
target.innerHTML = "";
target.style.overflow = "hidden";
target.style.position = "relative";
target.style.aspectRatio = "3/4";
for (const { layerKey, colorKey } of layers) {
const layerName = cosmetics?.flag?.layers[layerKey]?.name ?? layerKey;
const mask = assetUrl(`flags/custom/${layerName}.svg`);
if (!mask) continue;
const layer = document.createElement("div");
layer.style.position = "absolute";
layer.style.top = "0";
layer.style.left = "0";
layer.style.width = "100%";
layer.style.height = "100%";
const colorValue = cosmetics?.flag?.color[colorKey]?.color ?? colorKey;
const isSpecial =
!colorValue.startsWith("#") &&
!/^([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/.test(colorValue);
if (isSpecial) {
const duration = ANIMATION_DURATIONS[colorValue] ?? 5000;
const now = performance.now();
const offset = now % duration;
if (!duration) console.warn(`No animation duration for: ${colorValue}`);
layer.classList.add(`flag-color-${colorValue}`);
layer.style.animationDelay = `-${offset}ms`;
} else {
layer.style.backgroundColor = colorValue;
}
layer.style.maskImage = `url(${mask})`;
layer.style.maskRepeat = "no-repeat";
layer.style.maskPosition = "center";
layer.style.maskSize = "contain";
layer.style.webkitMaskImage = `url(${mask})`;
layer.style.webkitMaskRepeat = "no-repeat";
layer.style.webkitMaskPosition = "center";
layer.style.webkitMaskSize = "contain";
target.appendChild(layer);
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { placeName } from "../client/graphics/NameBoxCalculator";
import { getConfig } from "./configuration/ConfigLoader";
import { getGameLogicConfig } from "./configuration/ConfigLoader";
import { Executor } from "./execution/ExecutionManager";
import { RecomputeRailClusterExecution } from "./execution/RecomputeRailClusterExecution";
import { WinCheckExecution } from "./execution/WinCheckExecution";
@@ -35,7 +35,7 @@ export async function createGameRunner(
mapLoader: GameMapLoader,
callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
): Promise<GameRunner> {
const config = await getConfig(gameStart.config, null);
const config = await getGameLogicConfig(gameStart.config, null);
const gameMap = await loadGameMap(
gameStart.config.gameMap,
gameStart.config.gameMapSize,
+3 -1
View File
@@ -16,7 +16,9 @@ export class PseudoRandom {
// Generates a random integer between min (inclusive) and max (exclusive).
nextInt(min: number, max: number): number {
return Math.floor(this.rng() * (max - min)) + min;
const lo = Math.floor(min);
const hi = Math.floor(max);
return Math.floor(this.rng() * (hi - lo)) + lo;
}
// Generates a random float between min (inclusive) and max (exclusive).
+50 -28
View File
@@ -1,10 +1,9 @@
import countries from "resources/countries.json";
import quickChatData from "resources/QuickChat.json";
import { z } from "zod";
import {
ColorPaletteSchema,
CosmeticNameSchema,
PatternDataSchema,
PatternNameSchema,
} from "./CosmeticSchemas";
import type { GameEvent } from "./EventBus";
import {
@@ -132,7 +131,6 @@ export type PlayerCosmetics = z.infer<typeof PlayerCosmeticsSchema>;
export type PlayerCosmeticRefs = z.infer<typeof PlayerCosmeticRefsSchema>;
export type PlayerPattern = z.infer<typeof PlayerPatternSchema>;
export type PlayerColor = z.infer<typeof PlayerColorSchema>;
export type Flag = z.infer<typeof FlagSchema>;
export type GameStartInfo = z.infer<typeof GameStartInfoSchema>;
export type GameInfo = z.infer<typeof GameInfoSchema>;
export type PublicGames = z.infer<typeof PublicGamesSchema>;
@@ -225,13 +223,18 @@ export const GameConfigSchema = z.object({
gameMapSize: z.enum(GameMapSize),
publicGameModifiers: z
.object({
isCompact: z.boolean(),
isRandomSpawn: z.boolean(),
isCrowded: z.boolean(),
isHardNations: z.boolean(),
isCompact: z.boolean().optional(),
isRandomSpawn: z.boolean().optional(),
isCrowded: z.boolean().optional(),
isHardNations: z.boolean().optional(),
startingGold: z.number().int().min(0).optional(),
goldMultiplier: z.number().min(0.1).max(1000).optional(),
isAlliancesDisabled: z.boolean(),
isAlliancesDisabled: z.boolean().optional(),
isPortsDisabled: z.boolean().optional(),
isNukesDisabled: z.boolean().optional(),
isSAMsDisabled: z.boolean().optional(),
isPeaceTime: z.boolean().optional(),
isWaterNukes: z.boolean().optional(),
})
.optional(),
nations: z
@@ -245,15 +248,30 @@ export const GameConfigSchema = z.object({
infiniteTroops: z.boolean(),
instantBuild: z.boolean(),
disableNavMesh: z.boolean().optional(),
disableAlliances: z.boolean().optional(),
disableAlliances: z.boolean().nullable().optional(),
waterNukes: z.boolean().nullable().optional(),
randomSpawn: z.boolean(),
maxPlayers: z.number().optional(),
maxTimerValue: z.number().int().min(1).max(120).optional(), // In minutes
spawnImmunityDuration: z.number().int().min(0).optional(), // In ticks
maxTimerValue: z.number().int().min(1).max(120).nullable().optional(), // In minutes
spawnImmunityDuration: z.number().int().min(0).nullable().optional(), // In ticks
disabledUnits: z.enum(UnitType).array().optional(),
playerTeams: TeamCountConfigSchema.optional(),
goldMultiplier: z.number().min(0.1).max(1000).optional(),
startingGold: z.number().int().min(0).max(1000000000).optional(),
goldMultiplier: z.number().min(0.1).max(1000).nullable().optional(),
startingGold: z.number().int().min(0).max(1000000000).nullable().optional(),
hostCheats: z
.object({
infiniteGold: z.boolean().optional(),
infiniteTroops: z.boolean().optional(),
goldMultiplier: z.number().min(0.1).max(1000).nullable().optional(),
startingGold: z
.number()
.int()
.min(0)
.max(1000000000)
.nullable()
.optional(),
})
.optional(),
});
export const TeamSchema = z.string();
@@ -292,8 +310,6 @@ export const ID = z.string().regex(GAME_ID_REGEX);
export const AllPlayersStatsSchema = z.record(ID, PlayerStatsSchema);
const countryCodes = countries.filter((c) => !c.restricted).map((c) => c.code);
export const QuickChatKeySchema = z.enum(
Object.entries(quickChatData).flatMap(([category, entries]) =>
entries.map((entry) => `${category}.${entry.key}`),
@@ -400,7 +416,7 @@ export const CancelBoatIntentSchema = z.object({
export const MoveWarshipIntentSchema = z.object({
type: z.literal("move_warship"),
unitId: z.number(),
unitIds: z.array(z.number().int()).nonempty(),
tile: z.number(),
});
@@ -479,28 +495,23 @@ export const TurnSchema = z.object({
hash: z.number().nullable().optional(),
});
export const FlagSchema = z
export const FlagName = z
.string()
.max(128)
.optional()
.refine(
(val) => {
if (val === undefined || val === "") return true;
if (val.startsWith("!")) return true;
return countryCodes.includes(val);
return val.startsWith("flag:") || val.startsWith("country:");
},
{
message: "Invalid flag: must start with country: or flag:",
},
{ message: "Invalid flag: must be a valid country code or start with !" },
);
export const PlayerCosmeticRefsSchema = z.object({
flag: FlagSchema.optional(),
color: z.string().optional(),
patternName: PatternNameSchema.optional(),
patternColorPaletteName: z.string().optional(),
});
export const FlagSchema = z.string();
export const PlayerPatternSchema = z.object({
name: PatternNameSchema,
name: CosmeticNameSchema,
patternData: PatternDataSchema,
colorPalette: ColorPaletteSchema.optional(),
});
@@ -509,6 +520,16 @@ export const PlayerColorSchema = z.object({
color: z.string(),
});
// Refs contain cosmetics names, will be replaced by the actual
// content in the server
export const PlayerCosmeticRefsSchema = z.object({
flag: FlagName.optional(),
color: z.string().optional(),
patternName: CosmeticNameSchema.optional(),
patternColorPaletteName: z.string().optional(),
});
// Server converts refs to the actual cosmetics here
export const PlayerCosmeticsSchema = z.object({
flag: FlagSchema.optional(),
pattern: PlayerPatternSchema.optional(),
@@ -526,6 +547,7 @@ export const PlayerSchema = z.object({
export const GameStartInfoSchema = z.object({
gameID: ID,
lobbyCreatedAt: z.number(),
visibleAt: z.number().optional(),
config: GameConfigSchema,
players: PlayerSchema.array(),
});
+1
View File
@@ -94,6 +94,7 @@ export const OTHER_INDEX_LOST = 3; // Structures/warships destroyed/captured by
export const OTHER_INDEX_UPGRADE = 4; // Structures upgraded
export const BigIntStringSchema = z.preprocess((val) => {
if (val === null) return 0n;
if (typeof val === "string" && /^-?\d+$/.test(val)) return BigInt(val);
if (typeof val === "bigint") return val;
return val;
+4 -1
View File
@@ -251,6 +251,8 @@ export function createPartialGameRecord(
winner: Winner,
// lobby creation time (ms). Defaults to start time for singleplayer.
lobbyCreatedAt?: number,
// Time the lobby became visible to players (ms).
visibleAt?: number,
): PartialGameRecord {
const duration = Math.floor((end - start) / 1000);
const num_turns = allTurns.length;
@@ -262,13 +264,14 @@ export function createPartialGameRecord(
const actualLobbyCreatedAt = lobbyCreatedAt ?? start;
const lobbyFillTime = Math.max(
0,
start - Math.min(actualLobbyCreatedAt, start),
start - (visibleAt ?? actualLobbyCreatedAt),
);
const record: PartialGameRecord = {
info: {
gameID,
lobbyCreatedAt: actualLobbyCreatedAt,
visibleAt,
lobbyFillTime,
config,
players,
+3 -1
View File
@@ -75,6 +75,7 @@ export interface Config {
instantBuild(): boolean;
disableNavMesh(): boolean;
disableAlliances(): boolean;
waterNukes(): boolean;
isRandomSpawn(): boolean;
numSpawnPhaseTurns(): number;
userSettings(): UserSettings;
@@ -127,7 +128,7 @@ export interface Config {
defaultDonationAmount(sender: Player): number;
unitInfo(type: UnitType): UnitInfo;
tradeShipShortRangeDebuff(): number;
tradeShipGold(dist: number): Gold;
tradeShipGold(dist: number, player: Player | PlayerView): Gold;
tradeShipSpawnRate(
tradeShipSpawnRejections: number,
numTradeShips: number,
@@ -135,6 +136,7 @@ export interface Config {
trainGold(
rel: "self" | "team" | "ally" | "other",
citiesVisited: number,
player: Player | PlayerView,
): Gold;
trainSpawnRate(numPlayerFactories: number): number;
trainStationMinRange(): number;
+69 -22
View File
@@ -1,13 +1,18 @@
import { UserSettings } from "../game/UserSettings";
import { GameConfig } from "../Schemas";
import { Config, GameEnv, ServerConfig } from "./Config";
import { Config, ServerConfig } from "./Config";
import { DefaultConfig } from "./DefaultConfig";
import { DevConfig, DevServerConfig } from "./DevConfig";
import { Env } from "./Env";
import { preprodConfig } from "./PreprodConfig";
import { prodConfig } from "./ProdConfig";
export let cachedSC: ServerConfig | null = null;
export enum GameLogicEnv {
Dev = "dev",
Default = "default",
}
export let cachedRuntimeClientServerConfig: ServerConfig | null = null;
declare global {
interface Window {
@@ -17,35 +22,77 @@ declare global {
}
}
export async function getConfig(
export async function getGameLogicConfig(
gameConfig: GameConfig,
userSettings: UserSettings | null,
isReplay: boolean = false,
): Promise<Config> {
const sc = await getServerConfigFromClient();
switch (sc.env()) {
case GameEnv.Dev:
return new DevConfig(sc, gameConfig, userSettings, isReplay);
case GameEnv.Preprod:
case GameEnv.Prod:
console.log("using prod config");
return new DefaultConfig(sc, gameConfig, userSettings, isReplay);
const gameLogicEnv = getBuildTimeGameLogicEnv();
const serverConfig = getServerConfigForGameLogicEnv(gameLogicEnv);
switch (gameLogicEnv) {
case GameLogicEnv.Dev:
return new DevConfig(serverConfig, gameConfig, userSettings, isReplay);
case GameLogicEnv.Default:
return new DefaultConfig(
serverConfig,
gameConfig,
userSettings,
isReplay,
);
default:
throw Error(`unsupported server configuration: ${Env.GAME_ENV}`);
throw Error(`unsupported game logic environment: ${gameLogicEnv}`);
}
}
export async function getServerConfigFromClient(): Promise<ServerConfig> {
if (cachedSC) {
return cachedSC;
export function getBuildTimeGameLogicEnv(): GameLogicEnv {
const bundledGameEnv = process.env.GAME_ENV;
switch (bundledGameEnv) {
case "dev":
return GameLogicEnv.Dev;
case "staging":
case "prod":
return GameLogicEnv.Default;
case undefined:
throw new Error("Missing bundled game logic env");
default:
throw Error(`unsupported bundled game logic env: ${bundledGameEnv}`);
}
}
export function getServerConfigForGameLogicEnv(
gameLogicEnv: GameLogicEnv,
): ServerConfig {
switch (gameLogicEnv) {
case GameLogicEnv.Dev:
return new DevServerConfig();
case GameLogicEnv.Default:
console.log("using default game logic config");
return prodConfig;
default:
throw Error(`unsupported game logic environment: ${gameLogicEnv}`);
}
}
export async function getRuntimeClientServerConfig(): Promise<ServerConfig> {
if (cachedRuntimeClientServerConfig) {
return cachedRuntimeClientServerConfig;
}
const bootstrapGameEnv = window.BOOTSTRAP_CONFIG?.gameEnv;
if (!bootstrapGameEnv) {
throw new Error("Missing bootstrap server config");
if (typeof window === "undefined") {
throw new Error(
"Runtime client server config is only available on the browser main thread",
);
}
cachedSC = getServerConfig(bootstrapGameEnv);
return cachedSC;
const runtimeClientEnv = window.BOOTSTRAP_CONFIG?.gameEnv;
if (!runtimeClientEnv) {
throw new Error("Missing runtime client server config");
}
cachedRuntimeClientServerConfig = getServerConfig(runtimeClientEnv);
return cachedRuntimeClientServerConfig;
}
export function getServerConfigFromServer(): ServerConfig {
const gameEnv = Env.GAME_ENV;
@@ -67,6 +114,6 @@ export function getServerConfig(gameEnv: string) {
}
}
export function clearCachedServerConfig(): void {
cachedSC = null;
export function clearCachedRuntimeClientServerConfig(): void {
cachedRuntimeClientServerConfig = null;
}
+63 -13
View File
@@ -246,6 +246,9 @@ export class DefaultConfig implements Config {
disableAlliances(): boolean {
return this._gameConfig.disableAlliances ?? false;
}
waterNukes(): boolean {
return this._gameConfig.waterNukes ?? false;
}
isRandomSpawn(): boolean {
return this._gameConfig.randomSpawn;
}
@@ -268,7 +271,7 @@ export class DefaultConfig implements Config {
if (playerInfo.playerType === PlayerType.Bot) {
return 0n;
}
return BigInt(this._gameConfig.startingGold ?? 0);
return this.startingGoldFor(playerInfo);
}
trainSpawnRate(numPlayerFactories: number): number {
@@ -279,6 +282,7 @@ export class DefaultConfig implements Config {
trainGold(
rel: "self" | "team" | "ally" | "other",
citiesVisited: number,
player: Player | PlayerView,
): Gold {
// No penalty for the first 10 cities.
citiesVisited = Math.max(0, citiesVisited - 9);
@@ -297,7 +301,7 @@ export class DefaultConfig implements Config {
}
const distPenalty = citiesVisited * 5_000;
const gold = Math.max(5000, baseGold - distPenalty);
return toInt(gold * this.goldMultiplier());
return toInt(gold * this.goldMultiplierFor(player));
}
trainStationMinRange(): number {
@@ -310,13 +314,12 @@ export class DefaultConfig implements Config {
return 120;
}
tradeShipGold(dist: number): Gold {
tradeShipGold(dist: number, player: Player | PlayerView): Gold {
// Sigmoid: concave start, sharp S-curve middle, linear end - heavily punishes trades under range debuff.
const debuff = this.tradeShipShortRangeDebuff();
const baseGold =
75_000 / (1 + Math.exp(-0.03 * (dist - debuff))) + 50 * dist;
const multiplier = this.goldMultiplier();
return BigInt(Math.floor(baseGold * multiplier));
return BigInt(Math.floor(baseGold * this.goldMultiplierFor(player)));
}
// Probability of trade ship spawn = 1 / tradeShipSpawnRate
@@ -393,7 +396,10 @@ export class DefaultConfig implements Config {
case UnitType.MIRV:
info = {
cost: (game: Game, player: Player) => {
if (player.type() === PlayerType.Human && this.infiniteGold()) {
if (
player.type() === PlayerType.Human &&
this.hasInfiniteGoldFor(player)
) {
return 0n;
}
return 25_000_000n + game.stats().numMirvsLaunched() * 15_000_000n;
@@ -475,12 +481,55 @@ export class DefaultConfig implements Config {
return info;
}
private hasInfiniteGoldFor(player: Player | PlayerView): boolean {
if (this.infiniteGold()) return true;
const hc = this._gameConfig.hostCheats;
return (hc?.infiniteGold ?? false) && player.isLobbyCreator();
}
private hasInfiniteTroopsFor(player: Player | PlayerView): boolean {
if (this.infiniteTroops()) return true;
return (
(this._gameConfig.hostCheats?.infiniteTroops ?? false) &&
player.isLobbyCreator()
);
}
private hasInfiniteTroopsForInfo(playerInfo: PlayerInfo): boolean {
if (this.infiniteTroops()) return true;
return (
(this._gameConfig.hostCheats?.infiniteTroops ?? false) &&
playerInfo.isLobbyCreator
);
}
private goldMultiplierFor(player: Player | PlayerView): number {
const base = this.goldMultiplier();
const hc = this._gameConfig.hostCheats;
if (hc?.goldMultiplier && player.isLobbyCreator()) {
return hc.goldMultiplier;
}
return base;
}
private startingGoldFor(playerInfo: PlayerInfo): Gold {
const base = BigInt(this._gameConfig.startingGold ?? 0);
const hc = this._gameConfig.hostCheats;
if (hc?.startingGold && playerInfo.isLobbyCreator) {
return base + BigInt(hc.startingGold);
}
return base;
}
private costWrapper(
costFn: (units: number) => number,
...types: UnitType[]
): (g: Game, p: Player) => bigint {
return (game: Game, player: Player) => {
if (player.type() === PlayerType.Human && this.infiniteGold()) {
if (
player.type() === PlayerType.Human &&
this.hasInfiniteGoldFor(player)
) {
return 0n;
}
const numUnits = types.reduce(
@@ -669,7 +718,7 @@ export class DefaultConfig implements Config {
const altAttackerLoss =
1.3 * defenderTroopLoss * (mag / 100) * traitorMod;
const attackerTroopLoss =
0.7 * currentAttackerLoss + 0.3 * altAttackerLoss;
0.4 * currentAttackerLoss + 0.6 * altAttackerLoss;
return {
attackerTroopLoss,
@@ -758,16 +807,17 @@ export class DefaultConfig implements Config {
assertNever(this._gameConfig.difficulty);
}
}
return this.infiniteTroops() ? 1_000_000 : 25_000;
return this.hasInfiniteTroopsForInfo(playerInfo) ? 1_000_000 : 25_000;
}
maxTroops(player: Player | PlayerView): number {
const maxTroops =
player.type() === PlayerType.Human && this.infiniteTroops()
player.type() === PlayerType.Human && this.hasInfiniteTroopsFor(player)
? 1_000_000_000
: 2 * (Math.pow(player.numTilesOwned(), 0.6) * 1000 + 50000) +
: 2 * (Math.pow(player.numTilesOwned(), 0.7) * 1000 + 50000) +
player
.units(UnitType.City)
.filter((u) => !u.isUnderConstruction())
.map((city) => city.level())
.reduce((a, b) => a + b, 0) *
this.cityTroopIncrease();
@@ -797,7 +847,7 @@ export class DefaultConfig implements Config {
troopIncreaseRate(player: Player): number {
const max = this.maxTroops(player);
let toAdd = 10 + Math.pow(player.troops(), 0.73) / 4;
let toAdd = 10 + Math.pow(player.troops(), 0.8) / 4;
const ratio = 1 - player.troops() / max;
toAdd *= ratio;
@@ -829,7 +879,7 @@ export class DefaultConfig implements Config {
}
goldAdditionRate(player: Player): Gold {
const multiplier = this.goldMultiplier();
const multiplier = this.goldMultiplierFor(player);
let baseRate: bigint;
if (player.type() === PlayerType.Bot) {
baseRate = 50n;
+3
View File
@@ -283,6 +283,9 @@ export class AttackExecution implements Execution {
if (this.mg.owner(tileToConquer) !== this.target || !onBorder) {
continue;
}
if (!this.mg.isLand(tileToConquer)) {
continue;
}
this.addNeighbors(tileToConquer);
const { attackerTroopLoss, defenderTroopLoss, tilesPerTickUsed } = this.mg
.config()
+1 -1
View File
@@ -68,7 +68,7 @@ export class Executor {
case "cancel_boat":
return new BoatRetreatExecution(player, intent.unitID);
case "move_warship":
return new MoveWarshipExecution(player, intent.unitId, intent.tile);
return new MoveWarshipExecution(player, intent.unitIds, intent.tile);
case "spawn":
return new SpawnExecution(this.gameID, player.info(), intent.tile);
case "boat":
+20 -15
View File
@@ -4,31 +4,36 @@ import { TileRef } from "../game/GameMap";
export class MoveWarshipExecution implements Execution {
constructor(
private readonly owner: Player,
private readonly unitId: number,
private readonly unitIds: number[],
private readonly position: TileRef,
) {}
init(mg: Game, ticks: number): void {
init(mg: Game, _ticks: number): void {
if (!mg.isValidRef(this.position)) {
console.warn(`MoveWarshipExecution: position ${this.position} not valid`);
return;
}
const warship = this.owner
.units(UnitType.Warship)
.find((u) => u.id() === this.unitId);
if (!warship) {
console.warn("MoveWarshipExecution: warship not found");
return;
// Cache warship list and build a lookup map — avoids repeated iteration
const warshipMap = new Map(
this.owner.units(UnitType.Warship).map((u) => [u.id(), u]),
);
// Deduplicate ids so each warship is only moved once
for (const unitId of new Set(this.unitIds)) {
const warship = warshipMap.get(unitId);
if (!warship) {
console.warn(`MoveWarshipExecution: warship ${unitId} not found`);
continue;
}
if (!warship.isActive()) {
console.warn(`MoveWarshipExecution: warship ${unitId} is not active`);
continue;
}
warship.setPatrolTile(this.position);
warship.setTargetTile(undefined);
}
if (!warship.isActive()) {
console.warn("MoveWarshipExecution: warship is not active");
return;
}
warship.setPatrolTile(this.position);
warship.setTargetTile(undefined);
}
tick(ticks: number): void {}
tick(_ticks: number): void {}
isActive(): boolean {
return false;
+56 -7
View File
@@ -63,10 +63,60 @@ export class NukeExecution implements Execution {
const rand = new PseudoRandom(this.mg.ticks());
const inner2 = magnitude.inner * magnitude.inner;
const outer2 = magnitude.outer * magnitude.outer;
this.tilesToDestroyCache = this.mg.bfs(this.dst, (_, n: TileRef) => {
const d2 = this.mg?.euclideanDistSquared(this.dst, n) ?? 0;
return d2 <= outer2 && (d2 <= inner2 || rand.chance(2));
});
if (this.mg.config().waterNukes()) {
// Smooth irregular boundary for water nukes.
// Generate random radii at angular samples, then smooth them so the
// boundary undulates gently instead of creating spiky flower shapes.
// This avoids scattered land pixels that players would have to boat
// to individually in order to reclaim.
const NUM_SAMPLES = 16;
const radiiSq: number[] = new Array(NUM_SAMPLES);
for (let i = 0; i < NUM_SAMPLES; i++) {
radiiSq[i] = rand.nextFloat(inner2, outer2);
}
// Smooth the ring: 1 light pass (60% original, 20% each neighbour)
const prev = [...radiiSq];
for (let i = 0; i < NUM_SAMPLES; i++) {
const l = (i - 1 + NUM_SAMPLES) % NUM_SAMPLES;
const r = (i + 1) % NUM_SAMPLES;
radiiSq[i] = prev[i] * 0.6 + prev[l] * 0.2 + prev[r] * 0.2;
}
const cx = this.mg.x(this.dst);
const cy = this.mg.y(this.dst);
const outer = magnitude.outer;
const result = new Set<TileRef>();
const x0 = Math.max(0, cx - outer);
const y0 = Math.max(0, cy - outer);
const x1 = Math.min(this.mg.width() - 1, cx + outer);
const y1 = Math.min(this.mg.height() - 1, cy + outer);
for (let py = y0; py <= y1; py++) {
for (let px = x0; px <= x1; px++) {
const dx = px - cx;
const dy = py - cy;
const d2 = dx * dx + dy * dy;
if (d2 > outer2) continue;
if (d2 > inner2) {
const angle = Math.atan2(dy, dx) + Math.PI; // [0, 2π]
const t = (angle / (2 * Math.PI)) * NUM_SAMPLES;
const i0 = Math.floor(t) % NUM_SAMPLES;
const i1 = (i0 + 1) % NUM_SAMPLES;
const frac = t - Math.floor(t);
const threshold = radiiSq[i0] * (1 - frac) + radiiSq[i1] * frac;
if (d2 > threshold) continue;
}
result.add(this.mg.ref(px, py));
}
}
this.tilesToDestroyCache = result;
} else {
this.tilesToDestroyCache = this.mg.bfs(this.dst, (_, n: TileRef) => {
const d2 = this.mg?.euclideanDistSquared(this.dst, n) ?? 0;
return d2 <= outer2 && (d2 <= inner2 || rand.chance(2));
});
}
return this.tilesToDestroyCache;
}
@@ -89,7 +139,6 @@ export class NukeExecution implements Execution {
game: this.mg,
targetTile: this.dst,
magnitude,
allySmallIds: new Set(this.player.allies().map((a) => a.smallID())),
threshold: this.mg.config().nukeAllianceBreakThreshold(),
});
@@ -180,7 +229,6 @@ export class NukeExecution implements Execution {
// make the nuke unactive if it was intercepted
if (!this.nuke.isActive()) {
console.log(`Nuke destroyed before reaching target`);
this.active = false;
return;
}
@@ -267,8 +315,9 @@ export class NukeExecution implements Execution {
tilesPerPlayers.set(owner, (tilesPerPlayers.get(owner) ?? 0) + 1);
}
// Queue land tiles for batched water conversion
if (mg.isLand(tile)) {
mg.setFallout(tile, true);
mg.queueWaterConversion(tile);
}
}

Some files were not shown because too many files have changed in this diff Show More