mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-22 16:04:36 +00:00
Merge branch 'main' into player-text-opacity
This commit is contained in:
@@ -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
@@ -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[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>`
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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>`
|
||||
: ""}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -30,6 +30,6 @@
|
||||
}
|
||||
|
||||
.l-header__highlightText {
|
||||
color: #2563eb;
|
||||
color: #0073b7;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
+39
-15
@@ -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>;
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user