mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-07 16:16:00 +00:00
Merge branch 'main' into embeddedurlfix
This commit is contained in:
+14
-16
@@ -61,22 +61,22 @@ export class AccountModal extends BaseModal {
|
||||
|
||||
render() {
|
||||
const content = this.isLoadingUser
|
||||
? html`
|
||||
<div
|
||||
class="flex flex-col items-center justify-center p-12 text-white bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 h-full min-h-[400px]"
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin mb-4"
|
||||
></div>
|
||||
<p class="text-white/60 font-medium tracking-wide animate-pulse">
|
||||
${translateText("account_modal.fetching_account")}
|
||||
</p>
|
||||
</div>
|
||||
`
|
||||
? this.renderLoadingSpinner(
|
||||
translateText("account_modal.fetching_account"),
|
||||
)
|
||||
: this.renderInner();
|
||||
|
||||
if (this.inline) {
|
||||
return content;
|
||||
return this.isLoadingUser
|
||||
? html`<div class="${this.modalContainerClass}">
|
||||
${modalHeader({
|
||||
title: translateText("account_modal.title"),
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
})}
|
||||
${content}
|
||||
</div>`
|
||||
: content;
|
||||
}
|
||||
|
||||
return html`
|
||||
@@ -99,9 +99,7 @@ export class AccountModal extends BaseModal {
|
||||
const displayId = publicId || translateText("account_modal.not_found");
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden"
|
||||
>
|
||||
<div class="${this.modalContainerClass}">
|
||||
${modalHeader({
|
||||
title,
|
||||
onBack: () => this.close(),
|
||||
|
||||
@@ -10,6 +10,7 @@ export type UserAuth = { jwt: string; claims: TokenPayload } | false;
|
||||
const PERSISTENT_ID_KEY = "player_persistent_id";
|
||||
|
||||
let __jwt: string | null = null;
|
||||
let __refreshPromise: Promise<void> | null = null;
|
||||
|
||||
export function discordLogin() {
|
||||
const redirectUri = encodeURIComponent(window.location.href);
|
||||
@@ -138,6 +139,18 @@ export async function userAuth(
|
||||
}
|
||||
|
||||
async function refreshJwt(): Promise<void> {
|
||||
if (__refreshPromise) {
|
||||
return __refreshPromise;
|
||||
}
|
||||
__refreshPromise = doRefreshJwt();
|
||||
try {
|
||||
await __refreshPromise;
|
||||
} finally {
|
||||
__refreshPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function doRefreshJwt(): Promise<void> {
|
||||
try {
|
||||
console.log("Refreshing jwt");
|
||||
const response = await fetch(getApiBase() + "/auth/refresh", {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { createPartialGameRecord, replacer } from "../core/Util";
|
||||
import { ServerConfig } from "../core/configuration/Config";
|
||||
import { getConfig } from "../core/configuration/ConfigLoader";
|
||||
import { PlayerActions, UnitType } from "../core/game/Game";
|
||||
import { BuildableUnit, Structures, UnitType } from "../core/game/Game";
|
||||
import { TileRef } from "../core/game/GameMap";
|
||||
import { GameMapLoader } from "../core/game/GameMapLoader";
|
||||
import {
|
||||
@@ -227,6 +227,7 @@ async function createClientGame(
|
||||
config,
|
||||
gameMap,
|
||||
clientID,
|
||||
lobbyConfig.playerName,
|
||||
lobbyConfig.gameStartInfo.gameID,
|
||||
lobbyConfig.gameStartInfo.players,
|
||||
);
|
||||
@@ -384,15 +385,6 @@ export class ClientGameRunner {
|
||||
}
|
||||
});
|
||||
|
||||
const worker = this.worker;
|
||||
const keepWorkerAlive = () => {
|
||||
if (this.isActive) {
|
||||
worker.sendHeartbeat();
|
||||
requestAnimationFrame(keepWorkerAlive);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(keepWorkerAlive);
|
||||
|
||||
const onconnect = () => {
|
||||
console.log("Connected to game server!");
|
||||
this.transport.rejoinGame(this.turnsSeen);
|
||||
@@ -556,16 +548,15 @@ export class ClientGameRunner {
|
||||
if (myPlayer === null) return;
|
||||
this.myPlayer = myPlayer;
|
||||
}
|
||||
this.myPlayer.actions(tile).then((actions) => {
|
||||
if (this.myPlayer === null) return;
|
||||
this.myPlayer.actions(tile, [UnitType.TransportShip]).then((actions) => {
|
||||
if (actions.canAttack) {
|
||||
this.eventBus.emit(
|
||||
new SendAttackIntentEvent(
|
||||
this.gameView.owner(tile).id(),
|
||||
this.myPlayer.troops() * this.renderer.uiState.attackRatio,
|
||||
this.myPlayer!.troops() * this.renderer.uiState.attackRatio,
|
||||
),
|
||||
);
|
||||
} else if (this.canAutoBoat(actions, tile)) {
|
||||
} else if (this.canAutoBoat(actions.buildableUnits, tile)) {
|
||||
this.sendBoatAttackIntent(tile);
|
||||
}
|
||||
});
|
||||
@@ -600,7 +591,7 @@ export class ClientGameRunner {
|
||||
}
|
||||
|
||||
private findAndUpgradeNearestBuilding(clickedTile: TileRef) {
|
||||
this.myPlayer!.actions(clickedTile).then((actions) => {
|
||||
this.myPlayer!.actions(clickedTile, Structures.types).then((actions) => {
|
||||
const upgradeUnits: {
|
||||
unitId: number;
|
||||
unitType: UnitType;
|
||||
@@ -653,11 +644,17 @@ export class ClientGameRunner {
|
||||
this.myPlayer = myPlayer;
|
||||
}
|
||||
|
||||
this.myPlayer.actions(tile).then((actions) => {
|
||||
if (this.canBoatAttack(actions) !== false) {
|
||||
this.sendBoatAttackIntent(tile);
|
||||
}
|
||||
});
|
||||
this.myPlayer
|
||||
.buildables(tile, [UnitType.TransportShip])
|
||||
.then((buildables) => {
|
||||
if (this.canBoatAttack(buildables) !== false) {
|
||||
this.sendBoatAttackIntent(tile);
|
||||
} else {
|
||||
console.warn(
|
||||
"Boat attack triggered but can't send Transport Ship to tile",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private doGroundAttackUnderCursor(): void {
|
||||
@@ -672,13 +669,12 @@ export class ClientGameRunner {
|
||||
this.myPlayer = myPlayer;
|
||||
}
|
||||
|
||||
this.myPlayer.actions(tile).then((actions) => {
|
||||
if (this.myPlayer === null) return;
|
||||
this.myPlayer.actions(tile, null).then((actions) => {
|
||||
if (actions.canAttack) {
|
||||
this.eventBus.emit(
|
||||
new SendAttackIntentEvent(
|
||||
this.gameView.owner(tile).id(),
|
||||
this.myPlayer.troops() * this.renderer.uiState.attackRatio,
|
||||
this.myPlayer!.troops() * this.renderer.uiState.attackRatio,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -702,15 +698,9 @@ export class ClientGameRunner {
|
||||
return this.gameView.ref(cell.x, cell.y);
|
||||
}
|
||||
|
||||
private canBoatAttack(actions: PlayerActions): false | TileRef {
|
||||
const bu = actions.buildableUnits.find(
|
||||
(bu) => bu.type === UnitType.TransportShip,
|
||||
);
|
||||
if (bu === undefined) {
|
||||
console.warn(`no transport ship buildable units`);
|
||||
return false;
|
||||
}
|
||||
return bu.canBuild;
|
||||
private canBoatAttack(buildables: BuildableUnit[]): false | TileRef {
|
||||
const bu = buildables.find((bu) => bu.type === UnitType.TransportShip);
|
||||
return bu?.canBuild ?? false;
|
||||
}
|
||||
|
||||
private sendBoatAttackIntent(tile: TileRef) {
|
||||
@@ -724,10 +714,10 @@ export class ClientGameRunner {
|
||||
);
|
||||
}
|
||||
|
||||
private canAutoBoat(actions: PlayerActions, tile: TileRef): boolean {
|
||||
private canAutoBoat(buildables: BuildableUnit[], tile: TileRef): boolean {
|
||||
if (!this.gameView.isLand(tile)) return false;
|
||||
|
||||
const canBuild = this.canBoatAttack(actions);
|
||||
const canBuild = this.canBoatAttack(buildables);
|
||||
if (canBuild === false) return false;
|
||||
|
||||
// TODO: Global enable flag
|
||||
|
||||
+71
-1
@@ -5,7 +5,15 @@ import {
|
||||
CosmeticsSchema,
|
||||
Pattern,
|
||||
} from "../core/CosmeticSchemas";
|
||||
import { createCheckoutSession, getApiBase } from "./Api";
|
||||
import {
|
||||
PlayerCosmeticRefs,
|
||||
PlayerCosmetics,
|
||||
PlayerPattern,
|
||||
} from "../core/Schemas";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import { createCheckoutSession, getApiBase, getUserMe } from "./Api";
|
||||
|
||||
export const TEMP_FLARE_OFFSET = 1 * 60 * 1000; // 1 minute
|
||||
|
||||
export async function handlePurchase(
|
||||
pattern: Pattern,
|
||||
@@ -121,3 +129,65 @@ export function patternRelationship(
|
||||
// Patterns is for sale, and it's the right store to show it on.
|
||||
return "purchasable";
|
||||
}
|
||||
|
||||
export async function getPlayerCosmeticsRefs(): Promise<PlayerCosmeticRefs> {
|
||||
const userSettings = new UserSettings();
|
||||
const cosmetics = await fetchCosmetics();
|
||||
let pattern: PlayerPattern | null =
|
||||
userSettings.getSelectedPatternName(cosmetics);
|
||||
|
||||
if (pattern) {
|
||||
const userMe = await getUserMe();
|
||||
if (userMe) {
|
||||
const flareName =
|
||||
pattern.colorPalette?.name === undefined
|
||||
? `pattern:${pattern.name}`
|
||||
: `pattern:${pattern.name}:${pattern.colorPalette.name}`;
|
||||
const flares = userMe.player.flares ?? [];
|
||||
const hasWildcard = flares.includes("pattern:*");
|
||||
if (!hasWildcard && !flares.includes(flareName)) {
|
||||
pattern = null;
|
||||
}
|
||||
}
|
||||
if (pattern === null) {
|
||||
userSettings.setSelectedPatternName(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
flag: userSettings.getFlag(),
|
||||
color: userSettings.getSelectedColor() ?? undefined,
|
||||
patternName: pattern?.name ?? undefined,
|
||||
patternColorPaletteName: pattern?.colorPalette?.name ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPlayerCosmetics(): Promise<PlayerCosmetics> {
|
||||
const refs = await getPlayerCosmeticsRefs();
|
||||
const cosmetics = await fetchCosmetics();
|
||||
|
||||
const result: PlayerCosmetics = {};
|
||||
|
||||
if (refs.flag) {
|
||||
result.flag = refs.flag;
|
||||
}
|
||||
|
||||
if (refs.color) {
|
||||
result.color = { color: refs.color };
|
||||
}
|
||||
|
||||
if (refs.patternName && cosmetics) {
|
||||
const pattern = cosmetics.patterns[refs.patternName];
|
||||
if (pattern) {
|
||||
result.pattern = {
|
||||
name: refs.patternName,
|
||||
patternData: pattern.pattern,
|
||||
colorPalette: refs.patternColorPaletteName
|
||||
? cosmetics.colorPalettes?.[refs.patternColorPaletteName]
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ export class FlagInput extends LitElement {
|
||||
return html`
|
||||
<button
|
||||
id="flag-input"
|
||||
class="flag-btn p-0! m-0 border-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 transition-all duration-200 hover:scale-105 bg-slate-900/80 hover:bg-slate-800/80 active:bg-slate-800/90 rounded-lg overflow-hidden"
|
||||
class="flag-btn p-0 m-0 border-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 transition-all duration-200 hover:scale-105 bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] hover:brightness-[1.08] active:brightness-[0.95] rounded-lg overflow-hidden"
|
||||
title=${buttonTitle}
|
||||
@click=${this.onInputClick}
|
||||
>
|
||||
@@ -94,7 +94,7 @@ export class FlagInput extends LitElement {
|
||||
></span>
|
||||
${showSelect
|
||||
? html`<span
|
||||
class="text-[10px] font-black text-white/40 uppercase leading-none break-words w-full text-center px-1"
|
||||
class="text-[10px] font-black text-white uppercase leading-none break-words w-full text-center px-1"
|
||||
>
|
||||
${translateText("flag_input.title")}
|
||||
</span>`
|
||||
@@ -121,7 +121,8 @@ export class FlagInput extends LitElement {
|
||||
} else {
|
||||
const img = document.createElement("img");
|
||||
img.src = this.flag ? `/flags/${this.flag}.svg` : `/flags/xx.svg`;
|
||||
img.className = "w-full h-full object-cover drop-shadow";
|
||||
img.className = "w-full h-full object-cover pointer-events-none";
|
||||
img.draggable = false;
|
||||
img.onerror = () => {
|
||||
if (!img.src.endsWith("/flags/xx.svg")) {
|
||||
img.src = "/flags/xx.svg";
|
||||
|
||||
@@ -18,9 +18,7 @@ export class FlagInputModal extends BaseModal {
|
||||
|
||||
render() {
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden"
|
||||
>
|
||||
<div class="${this.modalContainerClass}">
|
||||
<div
|
||||
class="relative flex flex-col border-b border-white/10 pb-4 shrink-0"
|
||||
>
|
||||
@@ -61,7 +59,8 @@ export class FlagInputModal extends BaseModal {
|
||||
w-[100px] sm:w-[120px]"
|
||||
>
|
||||
<img
|
||||
class="w-full h-auto rounded shadow-sm group-hover:scale-105 transition-transform duration-200"
|
||||
class="w-full h-auto rounded group-hover:scale-105 transition-transform duration-200 pointer-events-none"
|
||||
draggable="false"
|
||||
src="/flags/${country.code}.svg"
|
||||
loading="lazy"
|
||||
@error=${(e: Event) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import { GameEndInfo } from "../core/Schemas";
|
||||
import { GameMapType, hasUnusualThumbnailSize } from "../core/game/Game";
|
||||
import { GameMapType } from "../core/game/Game";
|
||||
import { fetchGameById } from "./Api";
|
||||
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
|
||||
import { UsernameInput } from "./UsernameInput";
|
||||
@@ -107,7 +107,6 @@ export class GameInfoModal extends LitElement {
|
||||
if (!info) {
|
||||
return html``;
|
||||
}
|
||||
const isUnusualThumbnailSize = hasUnusualThumbnailSize(info.config.gameMap);
|
||||
return html`
|
||||
<div
|
||||
class="h-37.5 flex relative justify-between rounded-xl bg-black/20 items-center"
|
||||
@@ -115,9 +114,7 @@ export class GameInfoModal extends LitElement {
|
||||
${this.mapImage
|
||||
? html`<img
|
||||
src="${this.mapImage}"
|
||||
class="absolute place-self-start col-span-full row-span-full h-full rounded-xl mask-[linear-gradient(to_left,transparent,#fff)] ${isUnusualThumbnailSize
|
||||
? "object-cover object-center"
|
||||
: ""}"
|
||||
class="absolute place-self-start col-span-full row-span-full h-full rounded-xl mask-[linear-gradient(to_left,transparent,#fff)] object-cover object-center"
|
||||
/>`
|
||||
: html`<div
|
||||
class="place-self-start col-span-full row-span-full h-full rounded-xl bg-gray-300"
|
||||
@@ -180,7 +177,7 @@ export class GameInfoModal extends LitElement {
|
||||
try {
|
||||
const mapType = gameMap as GameMapType;
|
||||
const data = terrainMapFileLoader.getMapData(mapType);
|
||||
this.mapImage = await data.webpPath();
|
||||
this.mapImage = data.webpPath;
|
||||
} catch (error) {
|
||||
console.error("Failed to load map image:", error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
import { html, LitElement, nothing, type TemplateResult } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { getServerConfigFromClient } from "src/core/configuration/ConfigLoader";
|
||||
import {
|
||||
Duos,
|
||||
GameMapType,
|
||||
GameMode,
|
||||
HumansVsNations,
|
||||
Quads,
|
||||
Trios,
|
||||
} from "../core/game/Game";
|
||||
import { PublicGameInfo, PublicGames } from "../core/Schemas";
|
||||
import { HostLobbyModal } from "./HostLobbyModal";
|
||||
import { JoinLobbyModal } from "./JoinLobbyModal";
|
||||
import { PublicLobbySocket } from "./LobbySocket";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
import { SinglePlayerModal } from "./SinglePlayerModal";
|
||||
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
|
||||
import {
|
||||
getMapName,
|
||||
getModifierLabels,
|
||||
renderDuration,
|
||||
translateText,
|
||||
} from "./Utils";
|
||||
|
||||
const CARD_BG = "bg-[color-mix(in_oklab,var(--frenchBlue)_70%,black)]";
|
||||
|
||||
@customElement("game-mode-selector")
|
||||
export class GameModeSelector extends LitElement {
|
||||
@state() private lobbies: PublicGames | null = null;
|
||||
@state() private mapAspectRatios: Map<GameMapType, number> = new Map();
|
||||
private serverTimeOffset: number = 0;
|
||||
private defaultLobbyTime: number = 0;
|
||||
|
||||
private lobbySocket = new PublicLobbySocket((lobbies) =>
|
||||
this.handleLobbiesUpdate(lobbies),
|
||||
);
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates username input and shows error message if invalid.
|
||||
* Returns true if valid, false otherwise.
|
||||
*/
|
||||
private validateUsername(): boolean {
|
||||
const usernameInput = document.querySelector("username-input") as any;
|
||||
if (usernameInput?.isValid?.() === false) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
message: usernameInput.validationError,
|
||||
color: "red",
|
||||
duration: 3000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.lobbySocket.start();
|
||||
getServerConfigFromClient().then((config) => {
|
||||
this.defaultLobbyTime = config.gameCreationRate() / 1000;
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.stop();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
public stop() {
|
||||
this.lobbySocket.stop();
|
||||
}
|
||||
|
||||
private handleLobbiesUpdate(lobbies: PublicGames) {
|
||||
this.lobbies = lobbies;
|
||||
this.serverTimeOffset = lobbies.serverTime - Date.now();
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("public-lobbies-update", {
|
||||
detail: { payload: lobbies },
|
||||
}),
|
||||
);
|
||||
this.requestUpdate();
|
||||
|
||||
const allGames = Object.values(lobbies.games ?? {}).flat();
|
||||
for (const game of allGames) {
|
||||
const mapType = game.gameConfig?.gameMap as GameMapType;
|
||||
if (mapType && !this.mapAspectRatios.has(mapType)) {
|
||||
// New Map reference triggers Lit reactivity; placeholder ratio 1 lets
|
||||
// has() guard against duplicate in-flight fetches.
|
||||
this.mapAspectRatios = new Map(this.mapAspectRatios).set(mapType, 1);
|
||||
terrainMapFileLoader
|
||||
.getMapData(mapType)
|
||||
.manifest()
|
||||
.then((m: any) => {
|
||||
if (m?.map?.width && m?.map?.height) {
|
||||
this.mapAspectRatios = new Map(this.mapAspectRatios).set(
|
||||
mapType,
|
||||
m.map.width / m.map.height,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((e) =>
|
||||
console.error(`Failed to load manifest for ${mapType}`, e),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const ffa = this.lobbies?.games?.["ffa"]?.[0];
|
||||
const teams = this.lobbies?.games?.["team"]?.[0];
|
||||
const special = this.lobbies?.games?.["special"]?.[0];
|
||||
|
||||
return html`
|
||||
<div class="flex flex-col gap-4 w-[84%] sm:w-full mx-auto pb-4 sm:pb-0">
|
||||
<!-- Solo: mobile only, top -->
|
||||
<div class="sm:hidden h-14">
|
||||
${this.renderSmallActionCard(
|
||||
translateText("main.solo"),
|
||||
this.openSinglePlayerModal,
|
||||
"bg-sky-600",
|
||||
)}
|
||||
</div>
|
||||
<!-- Create/ranked/join: mobile only, below solo -->
|
||||
<div class="sm:hidden grid grid-cols-3 gap-4 h-14">
|
||||
${this.renderSmallActionCard(
|
||||
translateText("main.create"),
|
||||
this.openHostLobby,
|
||||
"bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)]",
|
||||
)}
|
||||
${this.renderSmallActionCard(
|
||||
translateText("mode_selector.ranked_title"),
|
||||
this.openRankedMenu,
|
||||
"bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)]",
|
||||
)}
|
||||
${this.renderSmallActionCard(
|
||||
translateText("main.join"),
|
||||
this.openJoinLobby,
|
||||
"bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)]",
|
||||
)}
|
||||
</div>
|
||||
<!-- Game cards grid -->
|
||||
<div
|
||||
class="grid grid-cols-1 sm:grid-cols-[2fr_1fr] gap-4 sm:h-[min(24rem,40vh)]"
|
||||
>
|
||||
<!-- Left col: main card (desktop only) -->
|
||||
${special
|
||||
? html`<div class="hidden sm:block">
|
||||
${this.renderSpecialLobbyCard(special)}
|
||||
</div>`
|
||||
: ffa
|
||||
? html`<div class="hidden sm:block">
|
||||
${this.renderLobbyCard(ffa, this.getLobbyTitle(ffa))}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
<!-- Right col: FFA + teams (desktop only) -->
|
||||
<div class="hidden sm:flex sm:flex-col sm:gap-4">
|
||||
${special && ffa
|
||||
? html`<div class="flex-1 min-h-0">
|
||||
${this.renderLobbyCard(ffa, this.getLobbyTitle(ffa))}
|
||||
</div>`
|
||||
: nothing}
|
||||
${teams
|
||||
? html`<div class="flex-1 min-h-0">
|
||||
${this.renderLobbyCard(teams, this.getLobbyTitle(teams))}
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
<!-- Mobile: special, ffa, teams inline -->
|
||||
<div class="sm:hidden">
|
||||
${special ? this.renderSpecialLobbyCard(special) : nothing}
|
||||
</div>
|
||||
<div class="sm:hidden">
|
||||
${ffa
|
||||
? this.renderLobbyCard(ffa, this.getLobbyTitle(ffa))
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="sm:hidden">
|
||||
${teams
|
||||
? this.renderLobbyCard(teams, this.getLobbyTitle(teams))
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Solo: full width, desktop only -->
|
||||
<div class="hidden sm:block h-14">
|
||||
${this.renderSmallActionCard(
|
||||
translateText("main.solo"),
|
||||
this.openSinglePlayerModal,
|
||||
"bg-sky-600",
|
||||
)}
|
||||
</div>
|
||||
<!-- Bottom row: create + ranked + join (desktop only) -->
|
||||
<div class="hidden sm:grid grid-cols-3 gap-4 h-14">
|
||||
${this.renderSmallActionCard(
|
||||
translateText("main.create"),
|
||||
this.openHostLobby,
|
||||
"bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)]",
|
||||
)}
|
||||
${this.renderSmallActionCard(
|
||||
translateText("mode_selector.ranked_title"),
|
||||
this.openRankedMenu,
|
||||
"bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)]",
|
||||
)}
|
||||
${this.renderSmallActionCard(
|
||||
translateText("main.join"),
|
||||
this.openJoinLobby,
|
||||
"bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)]",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSpecialLobbyCard(lobby: PublicGameInfo) {
|
||||
return this.renderLobbyCard(lobby, this.getLobbyTitle(lobby));
|
||||
}
|
||||
|
||||
private openRankedMenu = () => {
|
||||
if (!this.validateUsername()) return;
|
||||
window.showPage?.("page-ranked");
|
||||
};
|
||||
|
||||
private openSinglePlayerModal = () => {
|
||||
if (!this.validateUsername()) return;
|
||||
(
|
||||
document.querySelector("single-player-modal") as SinglePlayerModal
|
||||
)?.open();
|
||||
};
|
||||
|
||||
private openHostLobby = () => {
|
||||
if (!this.validateUsername()) return;
|
||||
(document.querySelector("host-lobby-modal") as HostLobbyModal)?.open();
|
||||
};
|
||||
|
||||
private openJoinLobby = () => {
|
||||
if (!this.validateUsername()) return;
|
||||
(document.querySelector("join-lobby-modal") as JoinLobbyModal)?.open();
|
||||
};
|
||||
|
||||
private renderSmallActionCard(
|
||||
title: string,
|
||||
onClick: () => void,
|
||||
bgClass: string = CARD_BG,
|
||||
) {
|
||||
return html`
|
||||
<button
|
||||
@click=${onClick}
|
||||
class="flex items-center justify-center w-full h-full rounded-xl ${bgClass} border-0 transition-transform hover:scale-[1.02] active:scale-[0.98] text-sm lg:text-base font-bold text-white uppercase tracking-wider text-center"
|
||||
>
|
||||
${title}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderLobbyCard(
|
||||
lobby: PublicGameInfo,
|
||||
titleContent: string | TemplateResult,
|
||||
) {
|
||||
const mapType = lobby.gameConfig!.gameMap as GameMapType;
|
||||
const mapImageSrc = terrainMapFileLoader.getMapData(mapType).webpPath;
|
||||
const aspectRatio = this.mapAspectRatios.get(mapType);
|
||||
// Use object-contain for extreme aspect ratios (e.g. Amazon River ~20:1) so
|
||||
// the full map is visible instead of being cropped by object-cover.
|
||||
const useContain =
|
||||
aspectRatio !== undefined && (aspectRatio > 4 || aspectRatio < 0.25);
|
||||
const timeRemaining = lobby.startsAt
|
||||
? Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
(lobby.startsAt - this.serverTimeOffset - Date.now()) / 1000,
|
||||
),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
let timeDisplay: string = "";
|
||||
let timeDisplayUppercase = false;
|
||||
if (timeRemaining === undefined) {
|
||||
timeDisplay = renderDuration(this.defaultLobbyTime);
|
||||
} else if (timeRemaining > 0) {
|
||||
timeDisplay = renderDuration(timeRemaining);
|
||||
} else {
|
||||
timeDisplay = translateText("public_lobby.starting_game");
|
||||
timeDisplayUppercase = true;
|
||||
}
|
||||
|
||||
const mapName = getMapName(lobby.gameConfig?.gameMap);
|
||||
|
||||
const modifierLabels = getModifierLabels(
|
||||
lobby.gameConfig?.publicGameModifiers,
|
||||
);
|
||||
// Sort by length for visual consistency (shorter labels first)
|
||||
if (modifierLabels.length > 1) {
|
||||
modifierLabels.sort((a, b) => a.length - b.length);
|
||||
}
|
||||
|
||||
return html`
|
||||
<button
|
||||
@click=${() => this.validateAndJoin(lobby)}
|
||||
class="group relative w-full h-44 sm:h-full text-white uppercase rounded-2xl transition-transform duration-200 hover:scale-[1.02] active:scale-[0.98]"
|
||||
style="background-color: color-mix(in oklab, var(--frenchBlue) 75%, black)"
|
||||
>
|
||||
<!-- Image clipped separately so overflow-hidden doesn't block absolute children -->
|
||||
<div
|
||||
class="absolute inset-0 rounded-2xl overflow-hidden pointer-events-none"
|
||||
>
|
||||
${mapImageSrc
|
||||
? html`<img
|
||||
src="${mapImageSrc}"
|
||||
alt="${mapName ?? lobby.gameConfig?.gameMap ?? "map"}"
|
||||
draggable="false"
|
||||
class="absolute inset-0 w-full h-full ${useContain
|
||||
? "object-contain"
|
||||
: "object-cover object-center scale-[1.05]"} [image-rendering:auto]"
|
||||
/>`
|
||||
: null}
|
||||
</div>
|
||||
<!-- Top row: modifiers + timer -->
|
||||
<div
|
||||
class="absolute inset-x-2 top-2 flex items-start justify-between gap-2"
|
||||
>
|
||||
${modifierLabels.length > 0
|
||||
? html`<div class="flex flex-col items-start gap-1">
|
||||
${modifierLabels.map(
|
||||
(label) =>
|
||||
html`<span
|
||||
class="px-2 py-0.5 rounded text-xs font-bold uppercase tracking-widest bg-teal-600 text-white shadow-[0_0_6px_rgba(13,148,136,0.35)]"
|
||||
>${label}</span
|
||||
>`,
|
||||
)}
|
||||
</div>`
|
||||
: html`<div></div>`}
|
||||
<div class="shrink-0">
|
||||
<span
|
||||
class="text-xs font-bold tracking-widest ${timeDisplayUppercase
|
||||
? "uppercase"
|
||||
: "normal-case"} bg-sky-600 px-2.5 py-1 rounded"
|
||||
>${timeDisplay}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Bottom bar: map name + mode, with player count floating above -->
|
||||
<div
|
||||
class="absolute bottom-0 left-0 right-0 flex flex-col px-3 py-2 bg-black/55 backdrop-blur-sm rounded-b-2xl"
|
||||
style="overflow: visible;"
|
||||
>
|
||||
<span
|
||||
class="absolute bottom-full right-2 mb-1 flex items-center gap-1 text-xs font-bold tracking-widest bg-black/70 backdrop-blur-sm px-2 py-0.5 rounded"
|
||||
>
|
||||
${lobby.numClients}/${lobby.gameConfig?.maxPlayers}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 inline-block"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z"
|
||||
></path>
|
||||
</svg>
|
||||
</span>
|
||||
${mapName
|
||||
? html`<p
|
||||
class="text-sm sm:text-base font-bold uppercase tracking-wider text-left leading-tight"
|
||||
>
|
||||
${mapName}
|
||||
</p>`
|
||||
: ""}
|
||||
<h3 class="text-xs text-white/70 uppercase tracking-wider text-left">
|
||||
${titleContent}
|
||||
</h3>
|
||||
</div>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private validateAndJoin(lobby: PublicGameInfo) {
|
||||
if (!this.validateUsername()) return;
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
gameID: lobby.gameID,
|
||||
source: "public",
|
||||
publicLobbyInfo: lobby,
|
||||
} as JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private getLobbyTitle(lobby: PublicGameInfo): string {
|
||||
const config = lobby.gameConfig!;
|
||||
if (config.gameMode === GameMode.FFA) {
|
||||
return translateText("game_mode.ffa");
|
||||
}
|
||||
|
||||
if (config?.gameMode === GameMode.Team) {
|
||||
const totalPlayers = config.maxPlayers ?? lobby.numClients ?? undefined;
|
||||
const formatTeamsOf = (
|
||||
teamCount: number | undefined,
|
||||
playersPerTeam: number | undefined,
|
||||
label?: string,
|
||||
) => {
|
||||
if (!teamCount)
|
||||
return label ?? translateText("mode_selector.teams_title");
|
||||
const baseTitle = playersPerTeam
|
||||
? translateText("mode_selector.teams_of", {
|
||||
teamCount: String(teamCount),
|
||||
playersPerTeam: String(playersPerTeam),
|
||||
})
|
||||
: translateText("mode_selector.teams_count", {
|
||||
teamCount: String(teamCount),
|
||||
});
|
||||
return `${baseTitle}${label ? ` (${label})` : ""}`;
|
||||
};
|
||||
|
||||
switch (config.playerTeams) {
|
||||
case Duos: {
|
||||
const teamCount = totalPlayers
|
||||
? Math.floor(totalPlayers / 2)
|
||||
: undefined;
|
||||
return formatTeamsOf(teamCount, 2);
|
||||
}
|
||||
case Trios: {
|
||||
const teamCount = totalPlayers
|
||||
? Math.floor(totalPlayers / 3)
|
||||
: undefined;
|
||||
return formatTeamsOf(teamCount, 3);
|
||||
}
|
||||
case Quads: {
|
||||
const teamCount = totalPlayers
|
||||
? Math.floor(totalPlayers / 4)
|
||||
: undefined;
|
||||
return formatTeamsOf(teamCount, 4);
|
||||
}
|
||||
case HumansVsNations: {
|
||||
const humanSlots = config.maxPlayers ?? lobby.numClients;
|
||||
return humanSlots
|
||||
? translateText("public_lobby.teams_hvn_detailed", {
|
||||
num: String(humanSlots),
|
||||
})
|
||||
: translateText("public_lobby.teams_hvn");
|
||||
}
|
||||
default:
|
||||
if (typeof config.playerTeams === "number") {
|
||||
const teamCount = config.playerTeams;
|
||||
const playersPerTeam =
|
||||
totalPlayers && teamCount > 0
|
||||
? Math.floor(totalPlayers / teamCount)
|
||||
: undefined;
|
||||
return formatTeamsOf(teamCount, playersPerTeam);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { Platform } from "./Platform";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -28,7 +29,7 @@ export class GoogleAdElement extends LitElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
if (isElectron()) {
|
||||
if (Platform.isElectron) {
|
||||
return html``;
|
||||
}
|
||||
return html`
|
||||
@@ -48,6 +49,10 @@ export class GoogleAdElement extends LitElement {
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
if (Platform.isElectron) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for the component to be fully rendered
|
||||
setTimeout(() => {
|
||||
try {
|
||||
@@ -61,37 +66,4 @@ export class GoogleAdElement extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if running in Electron
|
||||
const isElectron = () => {
|
||||
// Renderer process
|
||||
if (
|
||||
window !== undefined &&
|
||||
typeof window.process === "object" &&
|
||||
// @ts-expect-error hidden
|
||||
window.process.type === "renderer"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Main process
|
||||
if (
|
||||
process !== undefined &&
|
||||
typeof process.versions === "object" &&
|
||||
!!process.versions.electron
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Detect the user agent when the `nodeIntegration` option is set to false
|
||||
if (
|
||||
typeof navigator === "object" &&
|
||||
typeof navigator.userAgent === "string" &&
|
||||
navigator.userAgent.indexOf("Electron") >= 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export default GoogleAdElement;
|
||||
|
||||
@@ -120,8 +120,8 @@ export class GutterAds extends LitElement {
|
||||
return html`
|
||||
<!-- Left Gutter Ad -->
|
||||
<div
|
||||
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-[100] pointer-events-auto items-center justify-center"
|
||||
style="left: calc(50% - 10cm - 230px); top: calc(50% + 10px);"
|
||||
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-40 pointer-events-auto items-center justify-center"
|
||||
style="left: calc(50% - 10.5cm - 208px); top: calc(50% + 10px);"
|
||||
>
|
||||
<div
|
||||
id="${this.leftContainerId}"
|
||||
@@ -131,8 +131,8 @@ export class GutterAds extends LitElement {
|
||||
|
||||
<!-- Right Gutter Ad -->
|
||||
<div
|
||||
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-[100] pointer-events-auto items-center justify-center"
|
||||
style="left: calc(50% + 10cm + 70px); top: calc(50% + 10px);"
|
||||
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-40 pointer-events-auto items-center justify-center"
|
||||
style="left: calc(50% + 10.5cm + 48px); top: calc(50% + 10px);"
|
||||
>
|
||||
<div
|
||||
id="${this.rightContainerId}"
|
||||
|
||||
+14
-8
@@ -4,6 +4,7 @@ import { translateText, TUTORIAL_VIDEO_URL } from "../client/Utils";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/Difficulties";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import { Platform } from "./Platform";
|
||||
import { TroubleshootingModal } from "./TroubleshootingModal";
|
||||
|
||||
@customElement("help-modal")
|
||||
@@ -39,9 +40,10 @@ export class HelpModal extends BaseModal {
|
||||
console.warn("Invalid keybinds JSON:", e);
|
||||
}
|
||||
|
||||
const isMac = /Mac/.test(navigator.userAgent);
|
||||
const isMac = Platform.isMac;
|
||||
return {
|
||||
toggleView: "Space",
|
||||
coordinateGrid: "KeyM",
|
||||
centerCamera: "KeyC",
|
||||
moveUp: "KeyW",
|
||||
moveDown: "KeyS",
|
||||
@@ -99,14 +101,10 @@ export class HelpModal extends BaseModal {
|
||||
const keybinds = this.keybinds;
|
||||
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full flex flex-col ${this.inline
|
||||
? "bg-black/60 backdrop-blur-md rounded-2xl border border-white/10"
|
||||
: ""}"
|
||||
>
|
||||
<div class="${this.modalContainerClass}">
|
||||
${modalHeader({
|
||||
title: translateText("main.help"),
|
||||
onBack: this.close,
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
})}
|
||||
|
||||
@@ -153,7 +151,7 @@ export class HelpModal extends BaseModal {
|
||||
<iframe
|
||||
id="tutorial-video-iframe"
|
||||
class="absolute top-0 left-0 w-full h-full"
|
||||
src="${TUTORIAL_VIDEO_URL}"
|
||||
src="${this.isModalOpen ? TUTORIAL_VIDEO_URL : ""}"
|
||||
title="${translateText("help_modal.video_tutorial_title")}"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
@@ -269,6 +267,14 @@ export class HelpModal extends BaseModal {
|
||||
${translateText("help_modal.action_alt_view")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="hover:bg-white/5 transition-colors">
|
||||
<td class="py-3 pl-4 border-b border-white/5">
|
||||
${this.renderKey(keybinds.coordinateGrid)}
|
||||
</td>
|
||||
<td class="py-3 border-b border-white/5 text-white/70">
|
||||
${translateText("help_modal.action_coordinate_grid")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="hover:bg-white/5 transition-colors">
|
||||
<td class="py-3 pl-4 border-b border-white/5">
|
||||
${this.renderKey(keybinds.swapDirection)}
|
||||
|
||||
+369
-623
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,9 @@
|
||||
import { EventBus, GameEvent } from "../core/EventBus";
|
||||
import { UnitType } from "../core/game/Game";
|
||||
import { PlayerBuildableUnitType, UnitType } from "../core/game/Game";
|
||||
import { UnitView } from "../core/game/GameView";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import { UIState } from "./graphics/UIState";
|
||||
import { Platform } from "./Platform";
|
||||
import { ReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier";
|
||||
|
||||
export class MouseUpEvent implements GameEvent {
|
||||
@@ -82,11 +83,13 @@ export class RefreshGraphicsEvent implements GameEvent {}
|
||||
export class TogglePerformanceOverlayEvent implements GameEvent {}
|
||||
|
||||
export class ToggleStructureEvent implements GameEvent {
|
||||
constructor(public readonly structureTypes: UnitType[] | null) {}
|
||||
constructor(
|
||||
public readonly structureTypes: PlayerBuildableUnitType[] | null,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class GhostStructureChangedEvent implements GameEvent {
|
||||
constructor(public readonly ghostStructure: UnitType | null) {}
|
||||
constructor(public readonly ghostStructure: PlayerBuildableUnitType | null) {}
|
||||
}
|
||||
|
||||
export class SwapRocketDirectionEvent implements GameEvent {
|
||||
@@ -129,6 +132,10 @@ export class AutoUpgradeEvent implements GameEvent {
|
||||
) {}
|
||||
}
|
||||
|
||||
export class ToggleCoordinateGridEvent implements GameEvent {
|
||||
constructor(public readonly enabled: boolean) {}
|
||||
}
|
||||
|
||||
export class TickMetricsEvent implements GameEvent {
|
||||
constructor(
|
||||
public readonly tickExecutionDuration?: number,
|
||||
@@ -154,6 +161,7 @@ export class InputHandler {
|
||||
private moveInterval: NodeJS.Timeout | null = null;
|
||||
private activeKeys = new Set<string>();
|
||||
private keybinds: Record<string, string> = {};
|
||||
private coordinateGridEnabled = false;
|
||||
|
||||
private readonly PAN_SPEED = 5;
|
||||
private readonly ZOOM_SPEED = 10;
|
||||
@@ -197,10 +205,11 @@ export class InputHandler {
|
||||
}
|
||||
|
||||
// Mac users might have different keybinds
|
||||
const isMac = /Mac/.test(navigator.userAgent);
|
||||
const isMac = Platform.isMac;
|
||||
|
||||
this.keybinds = {
|
||||
toggleView: "Space",
|
||||
coordinateGrid: "KeyM",
|
||||
centerCamera: "KeyC",
|
||||
moveUp: "KeyW",
|
||||
moveDown: "KeyS",
|
||||
@@ -316,6 +325,14 @@ export class InputHandler {
|
||||
}
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.coordinateGrid && !e.repeat) {
|
||||
e.preventDefault();
|
||||
this.coordinateGridEnabled = !this.coordinateGridEnabled;
|
||||
this.eventBus.emit(
|
||||
new ToggleCoordinateGridEvent(this.coordinateGridEnabled),
|
||||
);
|
||||
}
|
||||
|
||||
if (e.code === "Escape") {
|
||||
e.preventDefault();
|
||||
this.eventBus.emit(new CloseViewEvent());
|
||||
@@ -378,12 +395,14 @@ export class InputHandler {
|
||||
|
||||
if (e.code === this.keybinds.attackRatioDown) {
|
||||
e.preventDefault();
|
||||
this.eventBus.emit(new AttackRatioEvent(-10));
|
||||
const increment = this.userSettings.attackRatioIncrement();
|
||||
this.eventBus.emit(new AttackRatioEvent(-increment));
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.attackRatioUp) {
|
||||
e.preventDefault();
|
||||
this.eventBus.emit(new AttackRatioEvent(10));
|
||||
const increment = this.userSettings.attackRatioIncrement();
|
||||
this.eventBus.emit(new AttackRatioEvent(increment));
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.centerCamera) {
|
||||
@@ -538,7 +557,8 @@ export class InputHandler {
|
||||
private onShiftScroll(event: WheelEvent) {
|
||||
if (event.shiftKey) {
|
||||
const scrollValue = event.deltaY === 0 ? event.deltaX : event.deltaY;
|
||||
const ratio = scrollValue > 0 ? -10 : 10;
|
||||
const increment = this.userSettings.attackRatioIncrement();
|
||||
const ratio = scrollValue > 0 ? -increment : increment;
|
||||
this.eventBus.emit(new AttackRatioEvent(ratio));
|
||||
}
|
||||
}
|
||||
@@ -591,7 +611,7 @@ export class InputHandler {
|
||||
this.eventBus.emit(new ContextMenuEvent(event.clientX, event.clientY));
|
||||
}
|
||||
|
||||
private setGhostStructure(ghostStructure: UnitType | null) {
|
||||
private setGhostStructure(ghostStructure: PlayerBuildableUnitType | null) {
|
||||
this.uiState.ghostStructure = ghostStructure;
|
||||
this.eventBus.emit(new GhostStructureChangedEvent(ghostStructure));
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import {
|
||||
getActiveModifiers,
|
||||
getGameModeLabel,
|
||||
normaliseMapKey,
|
||||
getMapName,
|
||||
renderDuration,
|
||||
renderNumber,
|
||||
translateText,
|
||||
@@ -16,14 +16,10 @@ import {
|
||||
GameInfo,
|
||||
GameRecordSchema,
|
||||
LobbyInfoEvent,
|
||||
PublicGameInfo,
|
||||
} from "../core/Schemas";
|
||||
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
||||
import {
|
||||
GameMapSize,
|
||||
GameMode,
|
||||
GameType,
|
||||
HumansVsNations,
|
||||
} from "../core/game/Game";
|
||||
import { GameMode, GameType, HumansVsNations } from "../core/game/Game";
|
||||
import { getApiBase } from "./Api";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
@@ -33,6 +29,7 @@ import "./components/CopyButton";
|
||||
import "./components/LobbyConfigItem";
|
||||
import "./components/LobbyPlayerView";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import { nationsConfigToSlider } from "./utilities/GameConfigHelpers";
|
||||
|
||||
@customElement("join-lobby-modal")
|
||||
export class JoinLobbyModal extends BaseModal {
|
||||
@@ -96,9 +93,7 @@ export class JoinLobbyModal extends BaseModal {
|
||||
? (this.lobbyCreatorClientID ?? "")
|
||||
: "";
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden select-none"
|
||||
>
|
||||
<div class="${this.modalContainerClass}">
|
||||
${modalHeader({
|
||||
title: translateText("public_lobby.title"),
|
||||
onBack: () => this.closeAndLeave(),
|
||||
@@ -135,11 +130,10 @@ export class JoinLobbyModal extends BaseModal {
|
||||
.lobbyCreatorClientID=${hostClientID}
|
||||
.currentClientID=${this.currentClientID}
|
||||
.teamCount=${this.gameConfig?.playerTeams ?? 2}
|
||||
.nationCount=${this.nationCount}
|
||||
.disableNations=${this.gameConfig?.disableNations ??
|
||||
false}
|
||||
.isCompactMap=${this.gameConfig?.gameMapSize ===
|
||||
GameMapSize.Compact}
|
||||
.nationCount=${nationsConfigToSlider(
|
||||
this.gameConfig?.nations ?? "default",
|
||||
this.nationCount,
|
||||
)}
|
||||
></lobby-player-view>
|
||||
`
|
||||
: ""}
|
||||
@@ -149,7 +143,7 @@ export class JoinLobbyModal extends BaseModal {
|
||||
${this.isPrivateLobby()
|
||||
? html`
|
||||
<div
|
||||
class="p-6 pt-4 border-t border-white/10 bg-black/20 shrink-0"
|
||||
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-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all shadow-lg shadow-blue-900/20 hover:shadow-blue-900/40 hover:-translate-y-0.5 active:translate-y-0 disabled:transform-none"
|
||||
@@ -161,7 +155,7 @@ export class JoinLobbyModal extends BaseModal {
|
||||
`
|
||||
: html`
|
||||
<div
|
||||
class="p-6 pt-4 border-t border-white/10 bg-black/20 shrink-0"
|
||||
class="p-6 lg:p-6 border-t border-white/10 bg-black/20 shrink-0"
|
||||
>
|
||||
<div
|
||||
class="w-full px-4 py-3 rounded-xl border border-white/10 bg-white/5 flex items-center justify-between gap-3"
|
||||
@@ -216,15 +210,13 @@ export class JoinLobbyModal extends BaseModal {
|
||||
|
||||
private renderJoinForm() {
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden select-none"
|
||||
>
|
||||
<div class="${this.modalContainerClass}">
|
||||
${modalHeader({
|
||||
title: translateText("private_lobby.title"),
|
||||
onBack: () => this.closeAndLeave(),
|
||||
ariaLabel: translateText("common.close"),
|
||||
})}
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4 mr-1">
|
||||
<form @submit=${this.joinLobbyFromInput} class="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4 mr-1">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
@@ -258,7 +250,7 @@ export class JoinLobbyModal extends BaseModal {
|
||||
<o-button
|
||||
title=${translateText("private_lobby.join_lobby")}
|
||||
block
|
||||
@click=${this.joinLobbyFromInput}
|
||||
submit
|
||||
></o-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -280,15 +272,12 @@ export class JoinLobbyModal extends BaseModal {
|
||||
`;
|
||||
}
|
||||
|
||||
public open(lobbyId: string = "", isPublic: boolean = false) {
|
||||
public open(lobbyId: string = "", lobbyInfo?: GameInfo | PublicGameInfo) {
|
||||
super.open();
|
||||
if (lobbyId) {
|
||||
this.startTrackingLobby(lobbyId);
|
||||
// If opened with lobbyInfo (public lobby case), auto-join the lobby
|
||||
if (isPublic) {
|
||||
this.joinPublicLobby(lobbyId);
|
||||
} else {
|
||||
// If opened with lobbyId but no lobbyInfo (URL join case), check if active and join
|
||||
this.startTrackingLobby(lobbyId, lobbyInfo);
|
||||
// If opened with lobbyId but no lobbyInfo (URL join case), auto-join the lobby
|
||||
if (!lobbyInfo) {
|
||||
this.handleUrlJoin(lobbyId);
|
||||
}
|
||||
}
|
||||
@@ -326,21 +315,10 @@ export class JoinLobbyModal extends BaseModal {
|
||||
}
|
||||
}
|
||||
|
||||
private joinPublicLobby(lobbyId: string) {
|
||||
// Dispatch join-lobby event to actually connect to the lobby
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
gameID: lobbyId,
|
||||
source: "public",
|
||||
} as JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private startTrackingLobby(lobbyId: string, lobbyInfo?: GameInfo) {
|
||||
private startTrackingLobby(
|
||||
lobbyId: string,
|
||||
lobbyInfo?: GameInfo | PublicGameInfo,
|
||||
) {
|
||||
this.currentLobbyId = lobbyId;
|
||||
// clientID will be assigned by server via lobby_info message
|
||||
this.currentClientID = "";
|
||||
@@ -355,7 +333,7 @@ export class JoinLobbyModal extends BaseModal {
|
||||
if (lobbyInfo) {
|
||||
this.updateFromLobby(lobbyInfo);
|
||||
// Only stop showing spinner when we have player info
|
||||
if (lobbyInfo.clients) {
|
||||
if ("clients" in lobbyInfo && lobbyInfo.clients) {
|
||||
this.isConnecting = false;
|
||||
}
|
||||
}
|
||||
@@ -436,7 +414,7 @@ export class JoinLobbyModal extends BaseModal {
|
||||
if (!this.gameConfig) return html``;
|
||||
|
||||
const c = this.gameConfig;
|
||||
const mapName = translateText("map." + normaliseMapKey(c.gameMap));
|
||||
const mapName = getMapName(c.gameMap);
|
||||
const modeName = getGameModeLabel(c);
|
||||
const modifiers = getActiveModifiers(c.publicGameModifiers);
|
||||
|
||||
@@ -454,9 +432,10 @@ export class JoinLobbyModal extends BaseModal {
|
||||
(m) => html`
|
||||
<lobby-config-item
|
||||
.label=${translateText(m.labelKey)}
|
||||
.value=${m.value !== undefined
|
||||
.value=${m.formattedValue ??
|
||||
(m.value !== undefined
|
||||
? renderNumber(m.value)
|
||||
: translateText("common.enabled")}
|
||||
: translateText("common.enabled"))}
|
||||
></lobby-config-item>
|
||||
`,
|
||||
)}
|
||||
@@ -530,8 +509,8 @@ export class JoinLobbyModal extends BaseModal {
|
||||
|
||||
// --- Lobby event handling ---
|
||||
|
||||
private updateFromLobby(lobby: GameInfo) {
|
||||
this.players = lobby.clients ?? [];
|
||||
private updateFromLobby(lobby: GameInfo | PublicGameInfo) {
|
||||
this.players = "clients" in lobby ? (lobby.clients ?? []) : [];
|
||||
this.lobbyStartAt = lobby.startsAt ?? null;
|
||||
this.syncCountdownTimer();
|
||||
if (lobby.gameConfig) {
|
||||
@@ -542,7 +521,10 @@ export class JoinLobbyModal extends BaseModal {
|
||||
}
|
||||
}
|
||||
|
||||
this.lobbyCreatorClientID = lobby.lobbyCreatorClientID ?? null;
|
||||
this.lobbyCreatorClientID =
|
||||
"lobbyCreatorClientID" in lobby
|
||||
? (lobby.lobbyCreatorClientID ?? null)
|
||||
: null;
|
||||
}
|
||||
|
||||
private startLobbyUpdates() {
|
||||
@@ -687,7 +669,8 @@ export class JoinLobbyModal extends BaseModal {
|
||||
}
|
||||
}
|
||||
|
||||
private async joinLobbyFromInput(): Promise<void> {
|
||||
private async joinLobbyFromInput(e: SubmitEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
const lobbyId = this.normalizeLobbyId(this.lobbyIdInput.value);
|
||||
if (!lobbyId) {
|
||||
this.showMessage(translateText("private_lobby.not_found"), "red");
|
||||
|
||||
@@ -218,7 +218,7 @@ export class LangSelector extends LitElement {
|
||||
"help-modal",
|
||||
"settings-modal",
|
||||
"username-input",
|
||||
"public-lobby",
|
||||
"game-mode-selector",
|
||||
"user-setting",
|
||||
"o-modal",
|
||||
"o-button",
|
||||
@@ -349,15 +349,16 @@ export class LangSelector extends LitElement {
|
||||
id="lang-selector"
|
||||
title="Change Language"
|
||||
@click=${this.openModal}
|
||||
class="border-none bg-none cursor-pointer p-0 flex items-center justify-center"
|
||||
class="border-none bg-none cursor-pointer p-0 flex items-center justify-center transition-transform duration-200 hover:scale-[1.1] active:scale-[0.9]"
|
||||
style="width: 28px; height: 28px;"
|
||||
>
|
||||
<img
|
||||
id="lang-flag"
|
||||
class="object-contain hover:scale-110 transition-transform duration-200"
|
||||
class="object-contain pointer-events-none"
|
||||
style="width: 28px; height: 28px;"
|
||||
src="/flags/${currentLang.svg}.svg"
|
||||
alt="flag"
|
||||
draggable="false"
|
||||
/>
|
||||
</button>
|
||||
`;
|
||||
|
||||
@@ -31,12 +31,12 @@ export class LanguageModal extends BaseModal {
|
||||
render() {
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden select-none"
|
||||
class="${this.modalContainerClass}"
|
||||
>
|
||||
<!-- Header -->
|
||||
${modalHeader({
|
||||
title: translateText("select_lang.title"),
|
||||
onBack: this.close,
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
})}
|
||||
|
||||
@@ -70,7 +70,7 @@ export class LanguageModal extends BaseModal {
|
||||
>
|
||||
<img
|
||||
src="/flags/${lang.svg}.svg"
|
||||
class="w-8 h-6 object-contain shadow-sm rounded-sm shrink-0"
|
||||
class="w-8 h-6 object-contain rounded-sm shrink-0"
|
||||
alt="${lang.code}"
|
||||
/>
|
||||
<div class="flex flex-col items-start min-w-0">
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Platform } from "./Platform";
|
||||
|
||||
export function initLayout() {
|
||||
// Wait for play-page component to render before setting up hamburger menu
|
||||
customElements.whenDefined("play-page").then(() => {
|
||||
@@ -6,7 +8,7 @@ export function initLayout() {
|
||||
const backdrop = document.getElementById("mobile-menu-backdrop");
|
||||
|
||||
// Force sidebar visibility style to ensure it's not hidden by other CSS
|
||||
if (sidebar && window.innerWidth < 768) {
|
||||
if (sidebar && Platform.isMobileWidth) {
|
||||
sidebar.style.display = "flex";
|
||||
}
|
||||
|
||||
@@ -59,7 +61,7 @@ export function initLayout() {
|
||||
// Close menu when clicking a menu link or button (Mobile only)
|
||||
sidebar.addEventListener("click", (e) => {
|
||||
// On desktop, we want the menu to stay open unless explicitly toggled
|
||||
if (window.innerWidth >= 768) return;
|
||||
if (!Platform.isMobileWidth) return;
|
||||
|
||||
// If the click happened on or inside an anchor/button/menu item, close the menu
|
||||
const clickedElement = (e.target as Element).closest
|
||||
@@ -75,7 +77,7 @@ export function initLayout() {
|
||||
|
||||
// Close on Escape (Mobile only)
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (window.innerWidth >= 768) return;
|
||||
if (!Platform.isMobileWidth) return;
|
||||
if (e.key === "Escape" && sidebar.classList.contains("open")) {
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
@@ -76,13 +76,13 @@ export class LeaderboardModal extends BaseModal {
|
||||
>(${start} - ${end})</span
|
||||
>`;
|
||||
}
|
||||
const refreshTime = html`<span
|
||||
class="text-sm font-normal text-white/40 ml-2 wrap-break-words italic"
|
||||
>(${translateText("leaderboard_modal.refresh_time")})</span
|
||||
>`;
|
||||
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full flex flex-col overflow-hidden ${this.inline
|
||||
? "bg-black/60 backdrop-blur-md rounded-2xl border border-white/10"
|
||||
: ""}"
|
||||
>
|
||||
<div class="${this.modalContainerClass}">
|
||||
${modalHeader({
|
||||
titleContent: html`
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@@ -92,9 +92,10 @@ export class LeaderboardModal extends BaseModal {
|
||||
${translateText("leaderboard_modal.title")}
|
||||
</span>
|
||||
${this.activeTab === "clans" ? dateRange : ""}
|
||||
${this.activeTab === "players" ? refreshTime : ""}
|
||||
</div>
|
||||
`,
|
||||
onBack: this.close,
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.close"),
|
||||
})}
|
||||
|
||||
|
||||
+43
-109
@@ -1,7 +1,13 @@
|
||||
import version from "resources/version.txt?raw";
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { EventBus } from "../core/EventBus";
|
||||
import { GAME_ID_REGEX, GameRecord, GameStartInfo } from "../core/Schemas";
|
||||
import {
|
||||
GAME_ID_REGEX,
|
||||
GameInfo,
|
||||
GameRecord,
|
||||
GameStartInfo,
|
||||
PublicGameInfo,
|
||||
} from "../core/Schemas";
|
||||
import { GameEnv } from "../core/configuration/Config";
|
||||
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
||||
import { GameType } from "../core/game/Game";
|
||||
@@ -10,13 +16,15 @@ import "./AccountModal";
|
||||
import { getUserMe } from "./Api";
|
||||
import { userAuth } from "./Auth";
|
||||
import { joinLobby } from "./ClientGameRunner";
|
||||
import { fetchCosmetics } from "./Cosmetics";
|
||||
import { getPlayerCosmeticsRefs } from "./Cosmetics";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
import "./FlagInput";
|
||||
import { FlagInput } from "./FlagInput";
|
||||
import "./FlagInputModal";
|
||||
import { FlagInputModal } from "./FlagInputModal";
|
||||
import { GameInfoModal } from "./GameInfoModal";
|
||||
import "./GameModeSelector";
|
||||
import { GameModeSelector } from "./GameModeSelector";
|
||||
import { GameStartingModal } from "./GameStartingModal";
|
||||
import "./GoogleAdElement";
|
||||
import { GutterAds } from "./GutterAds";
|
||||
@@ -32,9 +40,7 @@ import { MatchmakingModal } from "./Matchmaking";
|
||||
import { initNavigation } from "./Navigation";
|
||||
import "./NewsModal";
|
||||
import "./PatternInput";
|
||||
import "./PublicLobby";
|
||||
import { PublicLobby, ShowPublicLobbyModalEvent } from "./PublicLobby";
|
||||
import { SinglePlayerModal } from "./SinglePlayerModal";
|
||||
import "./SinglePlayerModal";
|
||||
import { TerritoryPatternsModal } from "./TerritoryPatternsModal";
|
||||
import { TokenLoginModal } from "./TokenLoginModal";
|
||||
import {
|
||||
@@ -55,6 +61,7 @@ import "./components/Footer";
|
||||
import "./components/MainLayout";
|
||||
import "./components/MobileNavBar";
|
||||
import "./components/PlayPage";
|
||||
import "./components/RankedModal";
|
||||
import "./components/baseComponents/Button";
|
||||
import "./components/baseComponents/Modal";
|
||||
import "./styles.css";
|
||||
@@ -197,15 +204,16 @@ declare global {
|
||||
BOLT_AD_CLICKED: string;
|
||||
SHOW_HIDDEN_CONTAINER: string;
|
||||
};
|
||||
currentPageId?: string;
|
||||
showPage?: (pageId: string) => void;
|
||||
}
|
||||
|
||||
// Extend the global interfaces to include your custom events
|
||||
interface DocumentEventMap {
|
||||
"join-lobby": CustomEvent<JoinLobbyEvent>;
|
||||
"show-public-lobby-modal": CustomEvent<ShowPublicLobbyModalEvent>;
|
||||
"kick-player": CustomEvent;
|
||||
"join-changed": CustomEvent;
|
||||
"open-matchmaking": CustomEvent<undefined>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +225,7 @@ export interface JoinLobbyEvent {
|
||||
// GameRecord exists when replaying an archived game.
|
||||
gameRecord?: GameRecord;
|
||||
source?: "public" | "private" | "host" | "matchmaking" | "singleplayer";
|
||||
publicLobbyInfo?: GameInfo | PublicGameInfo;
|
||||
}
|
||||
|
||||
class Client {
|
||||
@@ -230,21 +239,18 @@ class Client {
|
||||
|
||||
private hostModal: HostPrivateLobbyModal;
|
||||
private joinModal: JoinLobbyModal;
|
||||
private publicLobby: PublicLobby;
|
||||
private gameModeSelector: GameModeSelector;
|
||||
private userSettings: UserSettings = new UserSettings();
|
||||
private patternsModal: TerritoryPatternsModal;
|
||||
private tokenLoginModal: TokenLoginModal;
|
||||
private matchmakingModal: MatchmakingModal;
|
||||
|
||||
private gutterAds: GutterAds;
|
||||
|
||||
private turnstileTokenPromise: Promise<{
|
||||
token: string;
|
||||
createdAt: number;
|
||||
}> | null = null;
|
||||
|
||||
constructor() {}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
crazyGamesSDK.maybeInit();
|
||||
// Prefetch turnstile token so it is available when
|
||||
@@ -287,7 +293,9 @@ class Client {
|
||||
console.warn("Username input element not found");
|
||||
}
|
||||
|
||||
this.publicLobby = document.querySelector("public-lobby") as PublicLobby;
|
||||
this.gameModeSelector = document.querySelector(
|
||||
"game-mode-selector",
|
||||
) as GameModeSelector;
|
||||
|
||||
window.addEventListener("beforeunload", async () => {
|
||||
console.log("Browser is closing");
|
||||
@@ -303,41 +311,16 @@ class Client {
|
||||
this.gutterAds = gutterAds;
|
||||
|
||||
document.addEventListener("join-lobby", this.handleJoinLobby.bind(this));
|
||||
document.addEventListener(
|
||||
"show-public-lobby-modal",
|
||||
this.handleShowPublicLobbyModal.bind(this),
|
||||
);
|
||||
document.addEventListener("leave-lobby", this.handleLeaveLobby.bind(this));
|
||||
document.addEventListener("kick-player", this.handleKickPlayer.bind(this));
|
||||
document.addEventListener(
|
||||
"update-game-config",
|
||||
this.handleUpdateGameConfig.bind(this),
|
||||
);
|
||||
|
||||
const spModal = document.querySelector(
|
||||
"single-player-modal",
|
||||
) as SinglePlayerModal;
|
||||
if (!spModal || !(spModal instanceof SinglePlayerModal)) {
|
||||
console.warn("Singleplayer modal element not found");
|
||||
}
|
||||
|
||||
const singlePlayer = document.getElementById("single-player");
|
||||
if (singlePlayer === null) throw new Error("Missing single-player");
|
||||
singlePlayer.addEventListener("click", () => {
|
||||
if (this.usernameInput?.isValid()) {
|
||||
window.showPage?.("page-single-player");
|
||||
} else {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
message: this.usernameInput?.validationError,
|
||||
color: "red",
|
||||
duration: 3000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
document.addEventListener(
|
||||
"open-matchmaking",
|
||||
this.handleOpenMatchmaking.bind(this),
|
||||
);
|
||||
|
||||
const hlpModal = document.querySelector("help-modal") as HelpModal;
|
||||
if (!hlpModal || !(hlpModal instanceof HelpModal)) {
|
||||
@@ -506,23 +489,6 @@ class Client {
|
||||
} else {
|
||||
this.hostModal.eventBus = this.eventBus;
|
||||
}
|
||||
const hostLobbyButton = document.getElementById("host-lobby-button");
|
||||
if (hostLobbyButton === null) throw new Error("Missing host-lobby-button");
|
||||
hostLobbyButton.addEventListener("click", () => {
|
||||
if (this.usernameInput?.isValid()) {
|
||||
window.showPage?.("page-host-lobby");
|
||||
} else {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
message: this.usernameInput?.validationError,
|
||||
color: "red",
|
||||
duration: 3000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this.joinModal = document.querySelector(
|
||||
"join-lobby-modal",
|
||||
@@ -532,26 +498,6 @@ class Client {
|
||||
} else {
|
||||
this.joinModal.eventBus = this.eventBus;
|
||||
}
|
||||
const joinPrivateLobbyButton = document.getElementById(
|
||||
"join-private-lobby-button",
|
||||
);
|
||||
if (joinPrivateLobbyButton === null)
|
||||
throw new Error("Missing join-private-lobby-button");
|
||||
joinPrivateLobbyButton.addEventListener("click", () => {
|
||||
if (this.usernameInput?.isValid()) {
|
||||
window.showPage?.("page-join-lobby");
|
||||
} else {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
message: this.usernameInput?.validationError,
|
||||
color: "red",
|
||||
duration: 3000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (this.userSettings.darkMode()) {
|
||||
document.documentElement.classList.add("dark");
|
||||
@@ -767,8 +713,7 @@ class Client {
|
||||
(searchParams.toString() ? "?" + searchParams.toString() : "") +
|
||||
window.location.hash;
|
||||
history.replaceState(null, "", newUrl);
|
||||
// Wait for matchmaking button to be defined, then trigger its click handler
|
||||
// This goes through username validation instead of bypassing it
|
||||
// Wait for matchmaking button to be defined, then trigger its click handler.
|
||||
customElements.whenDefined("matchmaking-button").then(() => {
|
||||
const matchmakingButton = document.querySelector(
|
||||
"matchmaking-button button",
|
||||
@@ -792,30 +737,20 @@ class Client {
|
||||
this.gameStop(true);
|
||||
document.body.classList.remove("in-game");
|
||||
}
|
||||
if (lobby.source === "public") {
|
||||
this.joinModal?.open(lobby.gameID, lobby.publicLobbyInfo);
|
||||
}
|
||||
const config = await getServerConfigFromClient();
|
||||
// Only update URL immediately for private lobbies, not public ones
|
||||
if (lobby.source !== "public") {
|
||||
this.updateJoinUrlForShare(lobby.gameID, config);
|
||||
}
|
||||
|
||||
const pattern = this.userSettings.getSelectedPatternName(
|
||||
await fetchCosmetics(),
|
||||
);
|
||||
|
||||
this.gameStop = joinLobby(
|
||||
this.eventBus,
|
||||
{
|
||||
gameID: lobby.gameID,
|
||||
serverConfig: config,
|
||||
cosmetics: {
|
||||
color: this.userSettings.getSelectedColor() ?? undefined,
|
||||
patternName: pattern?.name ?? undefined,
|
||||
patternColorPaletteName: pattern?.colorPalette?.name ?? undefined,
|
||||
flag:
|
||||
this.flagInput === null || this.flagInput.getCurrentFlag() === "xx"
|
||||
? ""
|
||||
: this.flagInput.getCurrentFlag(),
|
||||
},
|
||||
cosmetics: await getPlayerCosmeticsRefs(),
|
||||
turnstileToken: await this.getTurnstileToken(lobby),
|
||||
playerName:
|
||||
this.usernameInput?.getCurrentUsername() ?? genAnonUsername(),
|
||||
@@ -862,7 +797,7 @@ class Client {
|
||||
modal.isModalOpen = false;
|
||||
}
|
||||
});
|
||||
this.publicLobby.stop();
|
||||
this.gameModeSelector.stop();
|
||||
document.querySelectorAll(".ad").forEach((ad) => {
|
||||
(ad as HTMLElement).style.display = "none";
|
||||
});
|
||||
@@ -878,8 +813,8 @@ class Client {
|
||||
}
|
||||
},
|
||||
() => {
|
||||
this.joinModal.close();
|
||||
this.publicLobby.stop();
|
||||
this.joinModal?.closeWithoutLeaving();
|
||||
this.gameModeSelector.stop();
|
||||
incrementGamesPlayed();
|
||||
|
||||
document.querySelectorAll(".ad").forEach((ad) => {
|
||||
@@ -897,10 +832,13 @@ class Client {
|
||||
if (window.location.hash === "" || window.location.hash === "#") {
|
||||
history.replaceState(null, "", window.location.origin + "#refresh");
|
||||
}
|
||||
const lobbyIdHidden = !this.userSettings.lobbyIdVisibility();
|
||||
history.pushState(
|
||||
null,
|
||||
"",
|
||||
`/${config.workerPath(lobby.gameID)}/game/${lobby.gameID}?live`,
|
||||
lobbyIdHidden
|
||||
? "/streamer-mode"
|
||||
: `/${config.workerPath(lobby.gameID)}/game/${lobby.gameID}?live`,
|
||||
);
|
||||
|
||||
// Store current URL for popstate confirmation
|
||||
@@ -913,7 +851,10 @@ class Client {
|
||||
lobbyId: string,
|
||||
config: Awaited<ReturnType<typeof getServerConfigFromClient>>,
|
||||
) {
|
||||
const targetUrl = `/${config.workerPath(lobbyId)}/game/${lobbyId}`;
|
||||
const lobbyIdHidden = !this.userSettings.lobbyIdVisibility();
|
||||
const targetUrl = lobbyIdHidden
|
||||
? "/streamer-mode"
|
||||
: `/${config.workerPath(lobbyId)}/game/${lobbyId}`;
|
||||
const currentUrl = window.location.pathname;
|
||||
|
||||
if (currentUrl !== targetUrl) {
|
||||
@@ -921,17 +862,6 @@ class Client {
|
||||
}
|
||||
}
|
||||
|
||||
private handleShowPublicLobbyModal(
|
||||
event: CustomEvent<ShowPublicLobbyModalEvent>,
|
||||
) {
|
||||
const { lobby } = event.detail;
|
||||
console.log(`Opening JoinLobbyModal for public lobby ${lobby.gameID}`);
|
||||
|
||||
// Open the join lobby modal page and pass the lobby info
|
||||
window.showPage?.("page-join-lobby");
|
||||
this.joinModal?.open(lobby.gameID, true);
|
||||
}
|
||||
|
||||
private async handleLeaveLobby(/* event: CustomEvent */) {
|
||||
if (this.gameStop === null) {
|
||||
return;
|
||||
@@ -952,6 +882,10 @@ class Client {
|
||||
crazyGamesSDK.gameplayStop();
|
||||
}
|
||||
|
||||
private handleOpenMatchmaking(_event: CustomEvent<undefined>) {
|
||||
this.matchmakingModal?.open();
|
||||
}
|
||||
|
||||
private handleKickPlayer(event: CustomEvent) {
|
||||
const { target } = event.detail;
|
||||
|
||||
|
||||
+21
-67
@@ -1,5 +1,5 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
||||
import { getUserMe, hasLinkedAccount } from "./Api";
|
||||
@@ -18,7 +18,7 @@ export class MatchmakingModal extends BaseModal {
|
||||
@state() private connected = false;
|
||||
@state() private socket: WebSocket | null = null;
|
||||
@state() private gameID: string | null = null;
|
||||
private elo: number | "unknown" = "unknown";
|
||||
private elo: number | string = "...";
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -37,14 +37,10 @@ export class MatchmakingModal extends BaseModal {
|
||||
`;
|
||||
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full flex flex-col ${this.inline
|
||||
? "bg-black/60 backdrop-blur-md rounded-2xl border border-white/10"
|
||||
: ""}"
|
||||
>
|
||||
<div class="${this.modalContainerClass}">
|
||||
${modalHeader({
|
||||
title: translateText("matchmaking_modal.title"),
|
||||
onBack: this.close,
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
})}
|
||||
<div class="flex-1 flex flex-col items-center justify-center gap-6 p-6">
|
||||
@@ -71,39 +67,21 @@ export class MatchmakingModal extends BaseModal {
|
||||
|
||||
private renderInner() {
|
||||
if (!this.connected) {
|
||||
return html`
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div
|
||||
class="w-12 h-12 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin"
|
||||
></div>
|
||||
<p class="text-center text-white/80">
|
||||
${translateText("matchmaking_modal.connecting")}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
return this.renderLoadingSpinner(
|
||||
translateText("matchmaking_modal.connecting"),
|
||||
"blue",
|
||||
);
|
||||
}
|
||||
if (this.gameID === null) {
|
||||
return html`
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div
|
||||
class="w-12 h-12 border-4 border-green-500/30 border-t-green-500 rounded-full animate-spin"
|
||||
></div>
|
||||
<p class="text-center text-white/80">
|
||||
${translateText("matchmaking_modal.searching")}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
return this.renderLoadingSpinner(
|
||||
translateText("matchmaking_modal.searching"),
|
||||
"green",
|
||||
);
|
||||
} else {
|
||||
return html`
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div
|
||||
class="w-12 h-12 border-4 border-yellow-500/30 border-t-yellow-500 rounded-full animate-spin"
|
||||
></div>
|
||||
<p class="text-center text-white/80">
|
||||
${translateText("matchmaking_modal.waiting_for_game")}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
return this.renderLoadingSpinner(
|
||||
translateText("matchmaking_modal.waiting_for_game"),
|
||||
"yellow",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,10 +152,13 @@ export class MatchmakingModal extends BaseModal {
|
||||
}),
|
||||
);
|
||||
this.close();
|
||||
window.showPage?.("page-account");
|
||||
return;
|
||||
}
|
||||
|
||||
this.elo = userMe.player.leaderboard?.oneVone?.elo ?? "unknown";
|
||||
this.elo =
|
||||
userMe.player.leaderboard?.oneVone?.elo ??
|
||||
translateText("matchmaking_modal.no_elo");
|
||||
|
||||
this.connected = false;
|
||||
this.gameID = null;
|
||||
@@ -241,7 +222,6 @@ export class MatchmakingModal extends BaseModal {
|
||||
|
||||
@customElement("matchmaking-button")
|
||||
export class MatchmakingButton extends LitElement {
|
||||
@query("matchmaking-modal") private matchmakingModal?: MatchmakingModal;
|
||||
@state() private isLoggedIn = false;
|
||||
|
||||
constructor() {
|
||||
@@ -281,7 +261,6 @@ export class MatchmakingButton extends LitElement {
|
||||
${translateText("matchmaking_button.description")}
|
||||
</span>
|
||||
</button>
|
||||
<matchmaking-modal></matchmaking-modal>
|
||||
`
|
||||
: html`
|
||||
<button
|
||||
@@ -296,35 +275,10 @@ export class MatchmakingButton extends LitElement {
|
||||
}
|
||||
|
||||
private handleLoggedInClick() {
|
||||
const usernameInput = document.querySelector("username-input") as any;
|
||||
const publicLobby = document.querySelector("public-lobby") as any;
|
||||
|
||||
if (usernameInput?.isValid()) {
|
||||
this.open();
|
||||
publicLobby?.leaveLobby();
|
||||
} else {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
message: usernameInput?.validationError,
|
||||
color: "red",
|
||||
duration: 3000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent("open-matchmaking"));
|
||||
}
|
||||
|
||||
private handleLoggedOutClick() {
|
||||
window.showPage?.("page-account");
|
||||
}
|
||||
|
||||
public open() {
|
||||
this.matchmakingModal?.open();
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.matchmakingModal?.close();
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
export function initNavigation() {
|
||||
const closeMobileSidebar = () => {
|
||||
const sidebar = document.getElementById("sidebar-menu");
|
||||
const backdrop = document.getElementById("mobile-menu-backdrop");
|
||||
if (sidebar?.classList.contains("open")) {
|
||||
sidebar.classList.remove("open");
|
||||
backdrop?.classList.remove("open");
|
||||
document.documentElement.classList.remove("overflow-hidden");
|
||||
sidebar.setAttribute("aria-hidden", "true");
|
||||
backdrop?.setAttribute("aria-hidden", "true");
|
||||
const hb = document.getElementById("hamburger-btn");
|
||||
if (hb) hb.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
};
|
||||
|
||||
const showPage = (pageId: string) => {
|
||||
(window as any).currentPageId = pageId;
|
||||
window.currentPageId = pageId;
|
||||
|
||||
// Close mobile sidebar if a nav item was clicked
|
||||
closeMobileSidebar();
|
||||
|
||||
// Hide only the currently visible modal
|
||||
const visibleModal = document.querySelector(".page-content:not(.hidden)");
|
||||
@@ -89,6 +106,13 @@ export function initNavigation() {
|
||||
) as any;
|
||||
|
||||
if (openModal && typeof openModal.close === "function") {
|
||||
// Check confirmation guard before closing
|
||||
if (
|
||||
typeof openModal.confirmBeforeClose === "function" &&
|
||||
!openModal.confirmBeforeClose()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Call leaveLobby or closeAndLeave first if it exists (for lobby modals)
|
||||
if (typeof openModal.leaveLobby === "function") {
|
||||
openModal.leaveLobby();
|
||||
@@ -106,9 +130,6 @@ export function initNavigation() {
|
||||
}
|
||||
});
|
||||
|
||||
// Set default page to play if no menu item is active
|
||||
const anyActive = document.querySelector(".nav-menu-item.active");
|
||||
if (!anyActive) {
|
||||
showPage("page-play");
|
||||
}
|
||||
// Ensure Play is the default visible/active page on load.
|
||||
showPage("page-play");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
const GITHUB_PR_URL_REGEX =
|
||||
/(?<!\()\bhttps:\/\/github\.com\/openfrontio\/OpenFrontIO\/pull\/(\d+)\b/g;
|
||||
const GITHUB_COMPARE_URL_REGEX =
|
||||
/(?<!\()\bhttps:\/\/github\.com\/openfrontio\/OpenFrontIO\/compare\/([\w.-]+)\b/g;
|
||||
const GITHUB_MENTION_REGEX =
|
||||
/(^|[^\w/[`])@([a-z\d](?:[a-z\d-]{0,37}[a-z\d])?)(?![\w-])/gim;
|
||||
|
||||
export function normalizeNewsMarkdown(markdown: string): string {
|
||||
return (
|
||||
markdown
|
||||
// Convert bold header lines (e.g. "**Title**") into real Markdown headers.
|
||||
// Exclude lines starting with - or * to avoid converting bullet points.
|
||||
.replace(/^([^\-*\s].*?) \*\*(.+?)\*\*$/gm, "## $1 $2")
|
||||
.replace(
|
||||
GITHUB_PR_URL_REGEX,
|
||||
(_match, prNumber) =>
|
||||
`[#${prNumber}](https://github.com/openfrontio/OpenFrontIO/pull/${prNumber})`,
|
||||
)
|
||||
.replace(
|
||||
GITHUB_COMPARE_URL_REGEX,
|
||||
(_match, comparison) =>
|
||||
`[${comparison}](https://github.com/openfrontio/OpenFrontIO/compare/${comparison})`,
|
||||
)
|
||||
.replace(
|
||||
GITHUB_MENTION_REGEX,
|
||||
(_match, prefix, username) =>
|
||||
`${prefix}[@${username}](https://github.com/${username})`,
|
||||
)
|
||||
);
|
||||
}
|
||||
+6
-23
@@ -6,6 +6,7 @@ import { translateText } from "../client/Utils";
|
||||
import "./components/baseComponents/Modal";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import { normalizeNewsMarkdown } from "./NewsMarkdown";
|
||||
import changelog from "/changelog.md?url";
|
||||
import megaphone from "/images/Megaphone.svg?url";
|
||||
|
||||
@@ -17,14 +18,10 @@ export class NewsModal extends BaseModal {
|
||||
|
||||
render() {
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full flex flex-col ${this.inline
|
||||
? "bg-black/60 backdrop-blur-md rounded-2xl border border-white/10"
|
||||
: ""}"
|
||||
>
|
||||
<div class="${this.modalContainerClass}">
|
||||
${modalHeader({
|
||||
title: translateText("news.title"),
|
||||
onBack: this.close,
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
})}
|
||||
<div
|
||||
@@ -67,23 +64,9 @@ export class NewsModal extends BaseModal {
|
||||
this.initialized = true;
|
||||
fetch(`${changelog}?v=${encodeURIComponent(version.trim())}`)
|
||||
.then((response) => (response.ok ? response.text() : "Failed to load"))
|
||||
.then((markdown) =>
|
||||
markdown
|
||||
// Convert bold header lines (e.g. "**Title**") into real Markdown headers
|
||||
// Exclude lines starting with - or * to avoid converting bullet points
|
||||
.replace(/^([^\-*\s].*?) \*\*(.+?)\*\*$/gm, "## $1 $2")
|
||||
.replace(
|
||||
/(?<!\()\bhttps:\/\/github\.com\/openfrontio\/OpenFrontIO\/pull\/(\d+)\b/g,
|
||||
(_match, prNumber) =>
|
||||
`[#${prNumber}](https://github.com/openfrontio/OpenFrontIO/pull/${prNumber})`,
|
||||
)
|
||||
.replace(
|
||||
/(?<!\()\bhttps:\/\/github\.com\/openfrontio\/OpenFrontIO\/compare\/([\w.-]+)\b/g,
|
||||
(_match, comparison) =>
|
||||
`[${comparison}](https://github.com/openfrontio/OpenFrontIO/compare/${comparison})`,
|
||||
),
|
||||
)
|
||||
.then((markdown) => (this.markdown = markdown));
|
||||
.then((markdown) => normalizeNewsMarkdown(markdown))
|
||||
.then((markdown) => (this.markdown = markdown))
|
||||
.catch(() => (this.markdown = "Failed to load"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+43
-27
@@ -1,10 +1,8 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { Cosmetics } from "../core/CosmeticSchemas";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import { PlayerPattern } from "../core/Schemas";
|
||||
import { renderPatternPreview } from "./components/PatternButton";
|
||||
import { fetchCosmetics } from "./Cosmetics";
|
||||
import { getPlayerCosmetics } from "./Cosmetics";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
@@ -17,24 +15,17 @@ export class PatternInput extends LitElement {
|
||||
@property({ type: Boolean, attribute: "show-select-label" })
|
||||
public showSelectLabel: boolean = false;
|
||||
|
||||
private userSettings = new UserSettings();
|
||||
private cosmetics: Cosmetics | null = null;
|
||||
@property({ type: Boolean, attribute: "adaptive-size" })
|
||||
public adaptiveSize: boolean = false;
|
||||
|
||||
private _abortController: AbortController | null = null;
|
||||
|
||||
private _onPatternSelected = () => {
|
||||
this.updateFromSettings();
|
||||
private _onPatternSelected = async () => {
|
||||
const cosmetics = await getPlayerCosmetics();
|
||||
this.selectedColor = cosmetics.color?.color ?? null;
|
||||
this.pattern = cosmetics.pattern ?? null;
|
||||
};
|
||||
|
||||
private updateFromSettings() {
|
||||
this.selectedColor = this.userSettings.getSelectedColor() ?? null;
|
||||
|
||||
if (this.cosmetics) {
|
||||
this.pattern = this.userSettings.getSelectedPatternName(this.cosmetics);
|
||||
} else {
|
||||
this.pattern = null;
|
||||
}
|
||||
}
|
||||
|
||||
private onInputClick(e: Event) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -50,10 +41,9 @@ export class PatternInput extends LitElement {
|
||||
super.connectedCallback();
|
||||
this._abortController = new AbortController();
|
||||
this.isLoading = true;
|
||||
const cosmetics = await fetchCosmetics();
|
||||
if (!this.isConnected) return;
|
||||
this.cosmetics = cosmetics;
|
||||
this.updateFromSettings();
|
||||
const cosmetics = await getPlayerCosmetics();
|
||||
this.selectedColor = cosmetics.color?.color ?? null;
|
||||
this.pattern = cosmetics.pattern ?? null;
|
||||
if (!this.isConnected) return;
|
||||
this.isLoading = false;
|
||||
window.addEventListener("pattern-selected", this._onPatternSelected, {
|
||||
@@ -73,13 +63,39 @@ export class PatternInput extends LitElement {
|
||||
return this;
|
||||
}
|
||||
|
||||
private getIsDefaultPattern(): boolean {
|
||||
return this.pattern === null && this.selectedColor === null;
|
||||
}
|
||||
|
||||
private shouldShowSelectLabel(): boolean {
|
||||
return this.showSelectLabel && this.getIsDefaultPattern();
|
||||
}
|
||||
|
||||
private applyAdaptiveSize(): void {
|
||||
if (!this.adaptiveSize) {
|
||||
this.style.removeProperty("width");
|
||||
this.style.removeProperty("height");
|
||||
return;
|
||||
}
|
||||
|
||||
const showSelect = this.showSelectLabel && this.getIsDefaultPattern();
|
||||
this.style.setProperty("height", "3rem");
|
||||
this.style.setProperty(
|
||||
"width",
|
||||
showSelect ? "clamp(6.5rem, 28vw, 9.5rem)" : "3rem",
|
||||
);
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
this.applyAdaptiveSize();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (crazyGamesSDK.isOnCrazyGames()) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const isDefault = this.pattern === null && this.selectedColor === null;
|
||||
const showSelect = this.showSelectLabel && isDefault;
|
||||
const showSelect = this.shouldShowSelectLabel();
|
||||
const buttonTitle = translateText("territory_patterns.title");
|
||||
|
||||
// Show loading state
|
||||
@@ -87,7 +103,7 @@ export class PatternInput extends LitElement {
|
||||
return html`
|
||||
<button
|
||||
id="pattern-input"
|
||||
class="pattern-btn m-0 border-0 !p-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 bg-slate-900/80 rounded-lg overflow-hidden"
|
||||
class="pattern-btn m-0 p-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] rounded-lg overflow-hidden"
|
||||
disabled
|
||||
>
|
||||
<span
|
||||
@@ -107,20 +123,20 @@ export class PatternInput extends LitElement {
|
||||
return html`
|
||||
<button
|
||||
id="pattern-input"
|
||||
class="pattern-btn m-0 border-0 !p-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 transition-all duration-200 hover:scale-105 bg-slate-900/80 hover:bg-slate-800/80 active:bg-slate-800/90 rounded-lg overflow-hidden"
|
||||
class="pattern-btn m-0 p-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 transition-all duration-200 hover:scale-105 bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] hover:brightness-[1.08] active:brightness-[0.95] rounded-lg overflow-hidden"
|
||||
title=${buttonTitle}
|
||||
@click=${this.onInputClick}
|
||||
>
|
||||
<span
|
||||
class=${showSelect
|
||||
? "hidden"
|
||||
: "w-full h-full overflow-hidden flex items-center justify-center [&>img]:object-cover [&>img]:w-full [&>img]:h-full"}
|
||||
: "w-full h-full overflow-hidden flex items-center justify-center [&>img]:object-cover [&>img]:w-full [&>img]:h-full [&>img]:pointer-events-none"}
|
||||
>
|
||||
${!showSelect ? previewContent : null}
|
||||
</span>
|
||||
${showSelect
|
||||
? html`<span
|
||||
class="text-[10px] font-black text-white/40 uppercase leading-none break-words w-full text-center px-1"
|
||||
class="text-[10px] font-black text-white uppercase leading-none break-words w-full text-center px-1"
|
||||
>
|
||||
${translateText("territory_patterns.select_skin")}
|
||||
</span>`
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
export const Platform = (() => {
|
||||
const isBrowser =
|
||||
typeof window !== "undefined" && typeof navigator !== "undefined";
|
||||
|
||||
const normalizePlatform = (platform: string): string => {
|
||||
const normalized = platform.toLowerCase();
|
||||
if (normalized.includes("windows")) return "Windows";
|
||||
if (
|
||||
normalized.includes("iphone") ||
|
||||
normalized.includes("ipad") ||
|
||||
normalized.includes("ipod") ||
|
||||
normalized.includes("ios")
|
||||
) {
|
||||
return "iOS";
|
||||
}
|
||||
if (
|
||||
normalized.includes("mac") ||
|
||||
normalized.includes("macintosh") ||
|
||||
normalized.includes("macos")
|
||||
) {
|
||||
return "macOS";
|
||||
}
|
||||
if (normalized.includes("android")) return "Android";
|
||||
if (normalized.includes("chrome os")) return "Linux";
|
||||
if (normalized.includes("linux")) return "Linux";
|
||||
return "Unknown";
|
||||
};
|
||||
|
||||
// OS Extraction
|
||||
const extractOS = (): string => {
|
||||
if (!isBrowser) return "Unknown";
|
||||
|
||||
const uaData = (navigator as any).userAgentData;
|
||||
if (uaData?.platform) {
|
||||
return normalizePlatform(uaData.platform);
|
||||
}
|
||||
|
||||
const ua = navigator.userAgent;
|
||||
if (/windows nt/i.test(ua)) return "Windows";
|
||||
if (/iphone|ipad|ipod/i.test(ua)) return "iOS";
|
||||
if (
|
||||
/mac os x/i.test(ua) &&
|
||||
((navigator.maxTouchPoints ?? 0) > 1 || /ipad/i.test(ua))
|
||||
) {
|
||||
return "iOS";
|
||||
}
|
||||
if (/mac os x/i.test(ua)) return "macOS";
|
||||
if (/android/i.test(ua)) return "Android";
|
||||
if (/linux/i.test(ua)) return "Linux";
|
||||
return "Unknown";
|
||||
};
|
||||
|
||||
const currentOS = extractOS();
|
||||
|
||||
// Environment Extraction
|
||||
const performElectronCheck = (): boolean => {
|
||||
// Renderer process
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
typeof (window as any).process === "object" &&
|
||||
(window as any).process.type === "renderer"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Main process
|
||||
if (
|
||||
typeof process !== "undefined" &&
|
||||
typeof process.versions === "object" &&
|
||||
!!process.versions.electron
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Detect the user agent when the `nodeIntegration` option is set to false
|
||||
if (
|
||||
isBrowser &&
|
||||
typeof navigator.userAgent === "string" &&
|
||||
navigator.userAgent.indexOf("Electron") >= 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const isMac = currentOS === "macOS";
|
||||
|
||||
return {
|
||||
os: currentOS,
|
||||
isMac,
|
||||
isWindows: currentOS === "Windows",
|
||||
isIOS: currentOS === "iOS",
|
||||
isAndroid: currentOS === "Android",
|
||||
isLinux: currentOS === "Linux",
|
||||
isElectron: performElectronCheck(),
|
||||
|
||||
get isMobileWidth(): boolean {
|
||||
return isBrowser ? window.innerWidth < 768 : false;
|
||||
},
|
||||
|
||||
get isTabletWidth(): boolean {
|
||||
return isBrowser
|
||||
? window.innerWidth >= 768 && window.innerWidth < 1024
|
||||
: false;
|
||||
},
|
||||
|
||||
get isDesktopWidth(): boolean {
|
||||
return isBrowser ? window.innerWidth >= 1024 : false;
|
||||
},
|
||||
};
|
||||
})();
|
||||
@@ -1,240 +0,0 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { GameMapType } from "../core/game/Game";
|
||||
import { GameID, PublicGameInfo, PublicGames } from "../core/Schemas";
|
||||
import { PublicLobbySocket } from "./LobbySocket";
|
||||
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
|
||||
import {
|
||||
getGameModeLabel,
|
||||
getModifierLabels,
|
||||
normaliseMapKey,
|
||||
renderDuration,
|
||||
translateText,
|
||||
} from "./Utils";
|
||||
|
||||
export interface ShowPublicLobbyModalEvent {
|
||||
lobby: PublicGameInfo;
|
||||
}
|
||||
|
||||
@customElement("public-lobby")
|
||||
export class PublicLobby extends LitElement {
|
||||
@state() private publicGames: PublicGames | null = null;
|
||||
@state() public isLobbyHighlighted: boolean = false;
|
||||
@state() private mapImages: Map<GameID, string> = new Map();
|
||||
|
||||
private lobbyIDToStart = new Map<GameID, number>();
|
||||
private serverTimeOffset = 0;
|
||||
private lobbySocket = new PublicLobbySocket((data) =>
|
||||
this.handleLobbiesUpdate(data),
|
||||
);
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.lobbySocket.start();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.lobbySocket.stop();
|
||||
}
|
||||
|
||||
private handleLobbiesUpdate(publicGames: PublicGames) {
|
||||
this.publicGames = publicGames;
|
||||
|
||||
// Calculate offset between server time and client time
|
||||
if (this.publicGames) {
|
||||
this.serverTimeOffset = this.publicGames.serverTime - Date.now();
|
||||
}
|
||||
this.publicGames.games.forEach((l) => {
|
||||
if (!this.lobbyIDToStart.has(l.gameID)) {
|
||||
// Convert server's startsAt to client time by subtracting offset
|
||||
const startsAt = l.startsAt ?? Date.now();
|
||||
this.lobbyIDToStart.set(l.gameID, startsAt - this.serverTimeOffset);
|
||||
}
|
||||
|
||||
if (l.gameConfig && !this.mapImages.has(l.gameID)) {
|
||||
this.loadMapImage(l.gameID, l.gameConfig.gameMap);
|
||||
}
|
||||
});
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private async loadMapImage(gameID: GameID, gameMap: string) {
|
||||
try {
|
||||
const mapType = gameMap as GameMapType;
|
||||
const data = terrainMapFileLoader.getMapData(mapType);
|
||||
this.mapImages.set(gameID, await data.webpPath());
|
||||
this.requestUpdate();
|
||||
} catch (error) {
|
||||
console.error("Failed to load map image:", error);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.publicGames) return html``;
|
||||
|
||||
const lobby = this.publicGames.games[0];
|
||||
if (!lobby?.gameConfig) return html``;
|
||||
|
||||
const start = this.lobbyIDToStart.get(lobby.gameID) ?? 0;
|
||||
const timeRemaining = Math.max(0, Math.floor((start - Date.now()) / 1000));
|
||||
const isStarting = timeRemaining <= 2;
|
||||
const timeDisplay = renderDuration(timeRemaining);
|
||||
|
||||
const modeLabel = getGameModeLabel(lobby.gameConfig);
|
||||
const modifierLabels = getModifierLabels(
|
||||
lobby.gameConfig.publicGameModifiers,
|
||||
);
|
||||
const mapImageSrc = this.mapImages.get(lobby.gameID);
|
||||
|
||||
return html`
|
||||
<button
|
||||
@click=${() => this.lobbyClicked(lobby)}
|
||||
class="group relative isolate flex flex-col w-full h-80 lg:h-96 overflow-hidden rounded-2xl transition-all duration-200 bg-[#3d7bab] hover:scale-[1.01] active:scale-[0.98] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/50"
|
||||
>
|
||||
<div class="font-sans w-full h-full flex flex-col">
|
||||
<!-- Main card gradient - stops before text -->
|
||||
<div class="absolute inset-0 pointer-events-none z-10"></div>
|
||||
|
||||
<!-- Map Image Area with gradient overlay -->
|
||||
<div class="flex-1 w-full relative overflow-hidden">
|
||||
${mapImageSrc
|
||||
? html`<img
|
||||
src="${mapImageSrc}"
|
||||
alt="${lobby.gameConfig.gameMap}"
|
||||
class="absolute inset-0 w-full h-full object-cover object-center z-10"
|
||||
/>`
|
||||
: ""}
|
||||
<!-- Vignette overlay for dark edges -->
|
||||
<div class="pointer-events-none absolute inset-0 z-20"></div>
|
||||
</div>
|
||||
|
||||
<!-- Mode Badge in top left -->
|
||||
${modeLabel
|
||||
? html`<span
|
||||
class="absolute top-4 left-4 px-4 py-1 rounded font-bold text-sm lg:text-base uppercase tracking-widest z-30 bg-slate-800 text-white ring-1 ring-white/10 shadow-sm"
|
||||
>
|
||||
${modeLabel}
|
||||
</span>`
|
||||
: ""}
|
||||
|
||||
<!-- Timer in top right -->
|
||||
${timeRemaining > 0
|
||||
? html`
|
||||
<span
|
||||
class="absolute top-4 right-4 px-4 py-1 rounded font-bold text-sm lg:text-base tracking-widest z-30 bg-blue-600 text-white"
|
||||
>
|
||||
${timeDisplay}
|
||||
</span>
|
||||
`
|
||||
: html`<span
|
||||
class="absolute top-4 right-4 px-4 py-1 rounded font-bold text-sm lg:text-base uppercase tracking-widest z-30 bg-green-600 text-white"
|
||||
>
|
||||
${translateText("public_lobby.started")}
|
||||
</span>`}
|
||||
|
||||
<!-- Content Banner -->
|
||||
<div class="absolute bottom-0 left-0 right-0 z-20">
|
||||
<!-- Modifier badges placed just above the gradient overlay -->
|
||||
${modifierLabels.length > 0
|
||||
? html`<div
|
||||
class="absolute -top-8 left-4 z-30 flex gap-2 flex-wrap"
|
||||
>
|
||||
${modifierLabels.map(
|
||||
(label) => html`
|
||||
<span
|
||||
class="px-2 py-0.5 rounded text-xs font-medium uppercase tracking-wide bg-purple-600 text-white"
|
||||
>
|
||||
${label}
|
||||
</span>
|
||||
`,
|
||||
)}
|
||||
</div>`
|
||||
: html``}
|
||||
|
||||
<!-- Gradient overlay for text area - adds extra darkening -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-b from-black/60 to-black/90 pointer-events-none"
|
||||
></div>
|
||||
|
||||
<div class="relative p-6 flex flex-col gap-2 text-left">
|
||||
<!-- Header row: Status/Join on left, Player Count on right -->
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="text-base uppercase tracking-widest text-white">
|
||||
${isStarting
|
||||
? html`<span class="text-green-400 animate-pulse"
|
||||
>${translateText("public_lobby.starting_game")}</span
|
||||
>`
|
||||
: html`${translateText("public_lobby.join")}`}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-white z-30">
|
||||
<span class="text-base font-bold uppercase tracking-widest"
|
||||
>${lobby.numClients}/${lobby.gameConfig.maxPlayers}</span
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Name - Full Width -->
|
||||
<div
|
||||
class="text-2xl lg:text-3xl font-bold text-white leading-none uppercase tracking-widest w-full"
|
||||
>
|
||||
${translateText(
|
||||
`map.${normaliseMapKey(lobby.gameConfig.gameMap)}`,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<!-- modifiers moved above gradient overlay -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
public stop() {
|
||||
this.lobbySocket.stop();
|
||||
}
|
||||
|
||||
private lobbyClicked(lobby: PublicGameInfo) {
|
||||
// Validate username before opening the modal
|
||||
const usernameInput = document.querySelector("username-input") as any;
|
||||
if (
|
||||
usernameInput &&
|
||||
typeof usernameInput.isValid === "function" &&
|
||||
!usernameInput.isValid()
|
||||
) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
message: usernameInput.validationError,
|
||||
color: "red",
|
||||
duration: 3000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("show-public-lobby-modal", {
|
||||
detail: { lobby } as ShowPublicLobbyModalEvent,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
+326
-575
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ import "./components/PatternButton";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import {
|
||||
fetchCosmetics,
|
||||
getPlayerCosmetics,
|
||||
handlePurchase,
|
||||
patternRelationship,
|
||||
} from "./Cosmetics";
|
||||
@@ -37,8 +38,8 @@ export class TerritoryPatternsModal extends BaseModal {
|
||||
|
||||
private userMeResponse: UserMeResponse | false = false;
|
||||
|
||||
private _onPatternSelected = () => {
|
||||
this.updateFromSettings();
|
||||
private _onPatternSelected = async () => {
|
||||
await this.updateFromSettings();
|
||||
this.refresh();
|
||||
};
|
||||
|
||||
@@ -62,24 +63,16 @@ export class TerritoryPatternsModal extends BaseModal {
|
||||
window.removeEventListener("pattern-selected", this._onPatternSelected);
|
||||
}
|
||||
|
||||
private updateFromSettings() {
|
||||
this.selectedPattern =
|
||||
this.cosmetics !== null
|
||||
? this.userSettings.getSelectedPatternName(this.cosmetics)
|
||||
: null;
|
||||
this.selectedColor = this.userSettings.getSelectedColor() ?? null;
|
||||
private async updateFromSettings() {
|
||||
const cosmetics = await getPlayerCosmetics();
|
||||
this.selectedPattern = cosmetics.pattern ?? null;
|
||||
this.selectedColor = cosmetics.color?.color ?? null;
|
||||
}
|
||||
|
||||
async onUserMe(userMeResponse: UserMeResponse | false) {
|
||||
if (!hasLinkedAccount(userMeResponse)) {
|
||||
this.userSettings.setSelectedPatternName(undefined);
|
||||
this.userSettings.setSelectedColor(undefined);
|
||||
this.selectedPattern = null;
|
||||
this.selectedColor = null;
|
||||
}
|
||||
this.userMeResponse = userMeResponse;
|
||||
this.cosmetics = await fetchCosmetics();
|
||||
this.updateFromSettings();
|
||||
await this.updateFromSettings();
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
@@ -87,7 +80,7 @@ export class TerritoryPatternsModal extends BaseModal {
|
||||
return html`
|
||||
${modalHeader({
|
||||
title: translateText("territory_patterns.title"),
|
||||
onBack: this.close,
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
rightContent: !hasLinkedAccount(this.userMeResponse)
|
||||
? html`<div class="flex items-center">
|
||||
@@ -212,11 +205,15 @@ export class TerritoryPatternsModal extends BaseModal {
|
||||
}
|
||||
|
||||
private renderNotLoggedInWarning(): TemplateResult {
|
||||
return html`<div
|
||||
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"
|
||||
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")}
|
||||
</div>`;
|
||||
</button>`;
|
||||
}
|
||||
|
||||
private renderColorSwatchGrid(): TemplateResult {
|
||||
@@ -251,11 +248,7 @@ export class TerritoryPatternsModal extends BaseModal {
|
||||
if (!this.isActive && !this.inline) return html``;
|
||||
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full flex flex-col ${this.inline
|
||||
? "bg-black/60 backdrop-blur-md rounded-2xl border border-white/10"
|
||||
: ""}"
|
||||
>
|
||||
<div class="${this.modalContainerClass}">
|
||||
${this.renderTabNavigation()}
|
||||
<div class="overflow-y-auto pr-2 custom-scrollbar mr-1">
|
||||
${this.activeTab === "patterns"
|
||||
|
||||
@@ -26,11 +26,7 @@ export class TokenLoginModal extends BaseModal {
|
||||
render() {
|
||||
const title = translateText("token_login_modal.title");
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full flex flex-col ${this.inline
|
||||
? "bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden"
|
||||
: ""}"
|
||||
>
|
||||
<div class="${this.modalContainerClass}">
|
||||
${modalHeader({
|
||||
title,
|
||||
onBack: () => this.close(),
|
||||
|
||||
+6
-12
@@ -55,13 +55,8 @@ export class SendUpgradeStructureIntentEvent implements GameEvent {
|
||||
) {}
|
||||
}
|
||||
|
||||
export class SendAllianceReplyIntentEvent implements GameEvent {
|
||||
constructor(
|
||||
// The original alliance requestor
|
||||
public readonly requestor: PlayerView,
|
||||
public readonly recipient: PlayerView,
|
||||
public readonly accepted: boolean,
|
||||
) {}
|
||||
export class SendAllianceRejectIntentEvent implements GameEvent {
|
||||
constructor(public readonly requestor: PlayerView) {}
|
||||
}
|
||||
|
||||
export class SendAllianceExtensionIntentEvent implements GameEvent {
|
||||
@@ -204,8 +199,8 @@ export class Transport {
|
||||
this.eventBus.on(SendAllianceRequestIntentEvent, (e) =>
|
||||
this.onSendAllianceRequest(e),
|
||||
);
|
||||
this.eventBus.on(SendAllianceReplyIntentEvent, (e) =>
|
||||
this.onAllianceRequestReplyUIEvent(e),
|
||||
this.eventBus.on(SendAllianceRejectIntentEvent, (e) =>
|
||||
this.onAllianceRejectUIEvent(e),
|
||||
);
|
||||
this.eventBus.on(SendAllianceExtensionIntentEvent, (e) =>
|
||||
this.onSendAllianceExtensionIntent(e),
|
||||
@@ -447,11 +442,10 @@ export class Transport {
|
||||
});
|
||||
}
|
||||
|
||||
private onAllianceRequestReplyUIEvent(event: SendAllianceReplyIntentEvent) {
|
||||
private onAllianceRejectUIEvent(event: SendAllianceRejectIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "allianceRequestReply",
|
||||
type: "allianceReject",
|
||||
requestor: event.requestor.id(),
|
||||
accept: event.accepted,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -30,11 +30,7 @@ export class TroubleshootingModal extends BaseModal {
|
||||
|
||||
render() {
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full select-text flex flex-col ${this.inline
|
||||
? "bg-black/60 backdrop-blur-md rounded-2xl border border-white/10"
|
||||
: ""}"
|
||||
>
|
||||
<div class="${this.modalContainerClass}">
|
||||
${modalHeader({
|
||||
titleContent: html` <div
|
||||
class="w-full flex flex-col sm:flex-row justify-between gap-2"
|
||||
@@ -56,7 +52,7 @@ export class TroubleshootingModal extends BaseModal {
|
||||
${translateText("common.copy")}
|
||||
</button>
|
||||
</div>`,
|
||||
onBack: this.close,
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
})}
|
||||
${this.loading
|
||||
|
||||
@@ -5,22 +5,24 @@ import { UserSettings } from "../core/game/UserSettings";
|
||||
import "./components/baseComponents/setting/SettingKeybind";
|
||||
import { SettingKeybind } from "./components/baseComponents/setting/SettingKeybind";
|
||||
import "./components/baseComponents/setting/SettingNumber";
|
||||
import "./components/baseComponents/setting/SettingSelect";
|
||||
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 =
|
||||
typeof navigator !== "undefined" && /Mac/.test(navigator.userAgent);
|
||||
const isMac = Platform.isMac;
|
||||
|
||||
const DefaultKeybinds: Record<string, string> = {
|
||||
toggleView: "Space",
|
||||
coordinateGrid: "KeyM",
|
||||
buildCity: "Digit1",
|
||||
buildFactory: "Digit2",
|
||||
buildPort: "Digit3",
|
||||
@@ -361,6 +363,23 @@ export class UserSettingModal extends BaseModal {
|
||||
}
|
||||
}
|
||||
|
||||
private changeAttackRatioIncrement(
|
||||
e: CustomEvent<{ value: number | string }>,
|
||||
) {
|
||||
const rawValue = e.detail?.value;
|
||||
const value =
|
||||
typeof rawValue === "number" ? rawValue : parseInt(String(rawValue), 10);
|
||||
if (!Number.isFinite(value)) {
|
||||
console.warn("Select event missing detail.value", e);
|
||||
return;
|
||||
}
|
||||
this.userSettings.setFloat(
|
||||
"settings.attackRatioIncrement",
|
||||
Math.round(value),
|
||||
);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private toggleTerritoryPatterns(e: CustomEvent<{ checked: boolean }>) {
|
||||
const enabled = e.detail?.checked;
|
||||
if (typeof enabled !== "boolean") return;
|
||||
@@ -394,20 +413,18 @@ export class UserSettingModal extends BaseModal {
|
||||
: this.renderKeybindSettings();
|
||||
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden"
|
||||
>
|
||||
<div class="${this.modalContainerClass}">
|
||||
<div
|
||||
class="relative flex flex-col border-b border-white/10 pb-4 shrink-0"
|
||||
class="relative flex flex-col border-b border-white/10 lg:pb-4 shrink-0"
|
||||
>
|
||||
${modalHeader({
|
||||
title: translateText("user_setting.title"),
|
||||
onBack: this.close,
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
showDivider: true,
|
||||
})}
|
||||
|
||||
<div class="hidden md:flex items-center gap-2 justify-center mt-4">
|
||||
<div class="hidden lg:flex items-center gap-2 justify-center mt-4">
|
||||
<button
|
||||
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
|
||||
.activeTab === "basic"
|
||||
@@ -475,6 +492,16 @@ export class UserSettingModal extends BaseModal {
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="coordinateGrid"
|
||||
label=${translateText("user_setting.coordinate_grid_label")}
|
||||
description=${translateText("user_setting.coordinate_grid_desc")}
|
||||
defaultKey=${DefaultKeybinds.coordinateGrid}
|
||||
.value=${this.getKeyValue("coordinateGrid")}
|
||||
.display=${this.getKeyChar("coordinateGrid")}
|
||||
@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"
|
||||
>
|
||||
@@ -616,7 +643,9 @@ export class UserSettingModal extends BaseModal {
|
||||
<setting-keybind
|
||||
action="attackRatioDown"
|
||||
label=${translateText("user_setting.attack_ratio_down")}
|
||||
description=${translateText("user_setting.attack_ratio_down_desc")}
|
||||
description=${translateText("user_setting.attack_ratio_down_desc", {
|
||||
amount: this.userSettings.attackRatioIncrement(),
|
||||
})}
|
||||
defaultKey="KeyT"
|
||||
.value=${this.getKeyValue("attackRatioDown")}
|
||||
.display=${this.getKeyChar("attackRatioDown")}
|
||||
@@ -626,7 +655,9 @@ export class UserSettingModal extends BaseModal {
|
||||
<setting-keybind
|
||||
action="attackRatioUp"
|
||||
label=${translateText("user_setting.attack_ratio_up")}
|
||||
description=${translateText("user_setting.attack_ratio_up_desc")}
|
||||
description=${translateText("user_setting.attack_ratio_up_desc", {
|
||||
amount: this.userSettings.attackRatioIncrement(),
|
||||
})}
|
||||
defaultKey="KeyY"
|
||||
.value=${this.getKeyValue("attackRatioUp")}
|
||||
.display=${this.getKeyChar("attackRatioUp")}
|
||||
@@ -895,6 +926,21 @@ export class UserSettingModal extends BaseModal {
|
||||
@change=${this.sliderAttackRatio}
|
||||
></setting-slider>
|
||||
|
||||
<!-- ⚔️ Attack Ratio Increment -->
|
||||
<setting-select
|
||||
label=${translateText("user_setting.attack_ratio_increment_label")}
|
||||
description=${translateText("user_setting.attack_ratio_increment_desc")}
|
||||
.options=${[
|
||||
{ value: 1, label: "1%" },
|
||||
{ value: 2, label: "2%" },
|
||||
{ value: 5, label: "5%" },
|
||||
{ value: 10, label: "10%" },
|
||||
{ value: 20, label: "20%" },
|
||||
]}
|
||||
.value=${String(this.userSettings.attackRatioIncrement())}
|
||||
@change=${this.changeAttackRatioIncrement}
|
||||
></setting-select>
|
||||
|
||||
${this.showEasterEggSettings
|
||||
? html`
|
||||
<setting-slider
|
||||
|
||||
@@ -78,7 +78,7 @@ export class UsernameInput extends LitElement {
|
||||
@input=${this.handleClanTagChange}
|
||||
placeholder="${translateText("username.tag")}"
|
||||
maxlength="5"
|
||||
class="w-[6rem] bg-transparent border-b border-white/20 text-white placeholder-white/30 text-xl font-bold text-center focus:outline-none focus:border-white/50 transition-colors uppercase shrink-0"
|
||||
class="w-[6rem] text-xl font-bold text-center uppercase shrink-0 bg-transparent text-white placeholder-white/70 focus:placeholder-transparent border-0 border-b border-white/40 focus:outline-none focus:border-white/60"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
@@ -86,7 +86,7 @@ export class UsernameInput extends LitElement {
|
||||
@input=${this.handleUsernameChange}
|
||||
placeholder="${translateText("username.enter_username")}"
|
||||
maxlength="${MAX_USERNAME_LENGTH}"
|
||||
class="flex-1 min-w-0 bg-transparent border-0 text-white placeholder-white/30 text-2xl font-bold text-left focus:outline-none focus:ring-0 transition-colors overflow-x-auto whitespace-nowrap text-ellipsis pr-2"
|
||||
class="flex-1 min-w-0 border-0 text-2xl font-bold text-left text-white placeholder-white/70 focus:outline-none focus:ring-0 overflow-x-auto whitespace-nowrap text-ellipsis pr-2 bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
${this.validationError
|
||||
@@ -147,8 +147,9 @@ export class UsernameInput extends LitElement {
|
||||
}
|
||||
|
||||
private validateAndStore() {
|
||||
// Validate base username meets minimum length (clan tag doesn't count)
|
||||
if (this.baseUsername.trim().length < MIN_USERNAME_LENGTH) {
|
||||
// Prevent empty username even if clan tag is present
|
||||
const trimmedBase = this.baseUsername.trim();
|
||||
if (!trimmedBase || trimmedBase.length < MIN_USERNAME_LENGTH) {
|
||||
this._isValid = false;
|
||||
this.validationError = translateText("username.too_short", {
|
||||
min: MIN_USERNAME_LENGTH,
|
||||
|
||||
+70
-32
@@ -6,10 +6,12 @@ import {
|
||||
MessageType,
|
||||
PublicGameModifiers,
|
||||
Quads,
|
||||
Team,
|
||||
Trios,
|
||||
} from "../core/game/Game";
|
||||
import { GameConfig } from "../core/Schemas";
|
||||
import type { LangSelector } from "./LangSelector";
|
||||
import { Platform } from "./Platform";
|
||||
|
||||
export const TUTORIAL_VIDEO_URL = "https://www.youtube.com/embed/EN2oOog3pSs";
|
||||
|
||||
@@ -17,6 +19,11 @@ export function normaliseMapKey(mapName: string): string {
|
||||
return mapName.toLowerCase().replace(/[\s.]+/g, "");
|
||||
}
|
||||
|
||||
export function getMapName(mapName: string | undefined): string | null {
|
||||
if (!mapName) return null;
|
||||
return translateText(`map.${normaliseMapKey(mapName)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a display label for the game mode (e.g. "FFA", "4 Teams", "Duos").
|
||||
*/
|
||||
@@ -104,8 +111,12 @@ export interface ModifierInfo {
|
||||
labelKey: string;
|
||||
/** Translation key for badge/short label (e.g. "public_game_modifier.random_spawn") */
|
||||
badgeKey: string;
|
||||
/** Parameters to pass to translateText for the badge key */
|
||||
badgeParams?: Record<string, string | number>;
|
||||
/** The raw value if applicable (e.g. startingGold amount) */
|
||||
value?: number;
|
||||
/** Pre-formatted display string (used instead of renderNumber when provided) */
|
||||
formattedValue?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,11 +145,24 @@ export function getActiveModifiers(
|
||||
badgeKey: "public_game_modifier.crowded",
|
||||
});
|
||||
}
|
||||
if (modifiers.isHardNations) {
|
||||
result.push({
|
||||
labelKey: "host_modal.hard_nations",
|
||||
badgeKey: "public_game_modifier.hard_nations",
|
||||
});
|
||||
}
|
||||
if (modifiers.startingGold) {
|
||||
const millions = parseFloat(
|
||||
(modifiers.startingGold / 1_000_000).toPrecision(12),
|
||||
);
|
||||
result.push({
|
||||
labelKey: "host_modal.starting_gold",
|
||||
badgeKey: "public_game_modifier.starting_gold",
|
||||
badgeParams: {
|
||||
amount: millions,
|
||||
},
|
||||
value: modifiers.startingGold,
|
||||
formattedValue: `${millions}M`,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
@@ -150,7 +174,9 @@ export function getActiveModifiers(
|
||||
export function getModifierLabels(
|
||||
modifiers: PublicGameModifiers | undefined,
|
||||
): string[] {
|
||||
return getActiveModifiers(modifiers).map((m) => translateText(m.badgeKey));
|
||||
return getActiveModifiers(modifiers).map((m) =>
|
||||
translateText(m.badgeKey, m.badgeParams),
|
||||
);
|
||||
}
|
||||
|
||||
export function renderDuration(totalSeconds: number): string {
|
||||
@@ -313,50 +339,65 @@ export function formatDebugTranslation(
|
||||
return `${key}::${serializedParams}`;
|
||||
}
|
||||
|
||||
const EMPTY_TRANSLATION_PARAMS: Record<string, string | number> = {};
|
||||
|
||||
function getCachedLangSelector(): LangSelector | null {
|
||||
const self = translateText as any;
|
||||
const cached = self.langSelector as LangSelector | null | undefined;
|
||||
if (cached && cached.isConnected) return cached;
|
||||
|
||||
const found = document.querySelector("lang-selector") as LangSelector | null;
|
||||
self.langSelector = found ?? null;
|
||||
return found;
|
||||
}
|
||||
|
||||
export const translateText = (
|
||||
key: string,
|
||||
params: Record<string, string | number> = {},
|
||||
params?: Record<string, string | number>,
|
||||
): string => {
|
||||
const self = translateText as any;
|
||||
self.formatterCache ??= new Map();
|
||||
self.lastLang ??= null;
|
||||
|
||||
const langSelector = document.querySelector("lang-selector") as LangSelector;
|
||||
const langSelector = getCachedLangSelector();
|
||||
if (!langSelector) {
|
||||
console.warn("LangSelector not found in DOM");
|
||||
return key;
|
||||
}
|
||||
|
||||
const resolvedParams = params ?? EMPTY_TRANSLATION_PARAMS;
|
||||
|
||||
if (langSelector.currentLang === "debug") {
|
||||
return formatDebugTranslation(key, params);
|
||||
return formatDebugTranslation(key, resolvedParams);
|
||||
}
|
||||
|
||||
if (
|
||||
!langSelector.translations ||
|
||||
Object.keys(langSelector.translations).length === 0
|
||||
) {
|
||||
return key;
|
||||
}
|
||||
const translations = langSelector.translations;
|
||||
const defaultTranslations = langSelector.defaultTranslations;
|
||||
if (!translations && !defaultTranslations) return key;
|
||||
|
||||
if (self.lastLang !== langSelector.currentLang) {
|
||||
self.formatterCache.clear();
|
||||
self.lastLang = langSelector.currentLang;
|
||||
}
|
||||
|
||||
let message = langSelector.translations[key];
|
||||
let message = translations?.[key];
|
||||
const hasPrimaryTranslation = message !== undefined;
|
||||
|
||||
if (!message && langSelector.defaultTranslations) {
|
||||
const defaultTranslations = langSelector.defaultTranslations;
|
||||
if (defaultTranslations && defaultTranslations[key]) {
|
||||
message = defaultTranslations[key];
|
||||
}
|
||||
message ??= defaultTranslations?.[key];
|
||||
|
||||
if (message === undefined) return key;
|
||||
|
||||
// Fast path: no params and no ICU placeholders.
|
||||
if (
|
||||
resolvedParams === EMPTY_TRANSLATION_PARAMS &&
|
||||
message.indexOf("{") === -1
|
||||
) {
|
||||
return message;
|
||||
}
|
||||
|
||||
if (!message) return key;
|
||||
|
||||
try {
|
||||
const locale =
|
||||
!langSelector.translations[key] && langSelector.currentLang !== "en"
|
||||
!hasPrimaryTranslation && langSelector.currentLang !== "en"
|
||||
? "en"
|
||||
: langSelector.currentLang;
|
||||
const cacheKey = `${key}:${locale}:${message}`;
|
||||
@@ -367,13 +408,20 @@ export const translateText = (
|
||||
self.formatterCache.set(cacheKey, formatter);
|
||||
}
|
||||
|
||||
return formatter.format(params) as string;
|
||||
return formatter.format(resolvedParams) as string;
|
||||
} catch (e) {
|
||||
console.warn("ICU format error", e);
|
||||
return message;
|
||||
}
|
||||
};
|
||||
|
||||
export function getTranslatedPlayerTeamLabel(team: Team | null): string {
|
||||
if (!team) return "";
|
||||
const translationKey = `team_colors.${team.toLowerCase()}`;
|
||||
const translated = translateText(translationKey);
|
||||
return translated === translationKey ? team : translated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Severity colors mapping for message types
|
||||
*/
|
||||
@@ -430,21 +478,11 @@ export function getMessageTypeClasses(type: MessageType): string {
|
||||
}
|
||||
|
||||
export function getModifierKey(): string {
|
||||
const isMac = /Mac/.test(navigator.userAgent);
|
||||
if (isMac) {
|
||||
return "⌘"; // Command key
|
||||
} else {
|
||||
return "Ctrl";
|
||||
}
|
||||
return Platform.isMac ? "⌘" : "Ctrl";
|
||||
}
|
||||
|
||||
export function getAltKey(): string {
|
||||
const isMac = /Mac/.test(navigator.userAgent);
|
||||
if (isMac) {
|
||||
return "⌥"; // Option key
|
||||
} else {
|
||||
return "Alt";
|
||||
}
|
||||
return Platform.isMac ? "⌥" : "Alt";
|
||||
}
|
||||
|
||||
export function getGamesPlayed(): number {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LitElement } from "lit";
|
||||
import { html, LitElement, TemplateResult } from "lit";
|
||||
import { property, query, state } from "lit/decorators.js";
|
||||
|
||||
/**
|
||||
@@ -10,11 +10,21 @@ import { property, query, state } from "lit/decorators.js";
|
||||
* - Automatic listener lifecycle management
|
||||
* - Common inline/modal element handling
|
||||
* - Shared open/close logic with hooks for custom behavior
|
||||
* - Standardized loading spinner UI
|
||||
* - Consistent modal container styling
|
||||
*/
|
||||
export abstract class BaseModal extends LitElement {
|
||||
@state() protected isModalOpen = false;
|
||||
@property({ type: Boolean }) inline = false;
|
||||
|
||||
/**
|
||||
* Standard modal container class string.
|
||||
* Provides consistent dark glassmorphic styling across all modals.
|
||||
* No rounding on mobile for full-screen appearance.
|
||||
*/
|
||||
protected readonly modalContainerClass =
|
||||
"h-full flex flex-col overflow-hidden bg-black/70 backdrop-blur-xl lg:rounded-2xl lg:border border-white/10";
|
||||
|
||||
@query("o-modal") protected modalEl?: HTMLElement & {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
@@ -29,6 +39,11 @@ export abstract class BaseModal extends LitElement {
|
||||
if (this.modalEl) {
|
||||
this.modalEl.onClose = () => {
|
||||
if (this.isModalOpen) {
|
||||
if (!this.confirmBeforeClose()) {
|
||||
// Re-open the underlying o-modal since it already closed itself
|
||||
this.modalEl?.open();
|
||||
return;
|
||||
}
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
@@ -47,6 +62,9 @@ export abstract class BaseModal extends LitElement {
|
||||
private handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && this.isModalOpen) {
|
||||
e.preventDefault();
|
||||
if (!this.confirmBeforeClose()) {
|
||||
return;
|
||||
}
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
@@ -83,6 +101,15 @@ export abstract class BaseModal extends LitElement {
|
||||
// Default implementation does nothing
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard called before closing via Escape key or click-outside.
|
||||
* Override in subclasses to show a confirmation dialog.
|
||||
* Return false to prevent the modal from closing.
|
||||
*/
|
||||
public confirmBeforeClose(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the modal. Handles both inline and modal element modes.
|
||||
* Subclasses can override onOpen() for custom behavior.
|
||||
@@ -121,4 +148,43 @@ export abstract class BaseModal extends LitElement {
|
||||
this.modalEl?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a standardized loading spinner with optional custom message.
|
||||
* Use this for consistent loading states across all modals.
|
||||
*
|
||||
* @param message - Optional loading message text. Defaults to no message.
|
||||
* @param spinnerColor - Optional spinner color. Defaults to 'blue'.
|
||||
* @returns TemplateResult of the loading UI
|
||||
*/
|
||||
protected renderLoadingSpinner(
|
||||
message?: string,
|
||||
spinnerColor: "blue" | "green" | "yellow" | "white" = "blue",
|
||||
): TemplateResult {
|
||||
const colorClasses = {
|
||||
blue: "border-blue-500/30 border-t-blue-500",
|
||||
green: "border-green-500/30 border-t-green-500",
|
||||
yellow: "border-yellow-500/30 border-t-yellow-500",
|
||||
white: "border-white/20 border-t-white",
|
||||
};
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="flex flex-col items-center justify-center p-12 text-white h-full min-h-[400px]"
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 border-4 ${colorClasses[
|
||||
spinnerColor
|
||||
]} rounded-full animate-spin mb-4"
|
||||
></div>
|
||||
${message
|
||||
? html`<p
|
||||
class="text-white/60 font-medium tracking-wide animate-pulse"
|
||||
>
|
||||
${message}
|
||||
</p>`
|
||||
: ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { getCosmeticsHash } from "../Cosmetics";
|
||||
import { getGamesPlayed } from "../Utils";
|
||||
|
||||
const HELP_SEEN_KEY = "helpSeen";
|
||||
const STORE_SEEN_HASH_KEY = "storeSeenHash";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { NavNotificationsController } from "./NavNotificationsController";
|
||||
|
||||
@customElement("desktop-nav-bar")
|
||||
export class DesktopNavBar extends LitElement {
|
||||
@state() private _helpSeen = localStorage.getItem(HELP_SEEN_KEY) === "true";
|
||||
@state() private _hasNewCosmetics = false;
|
||||
private _notifications = new NavNotificationsController(this);
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
@@ -19,19 +14,13 @@ export class DesktopNavBar extends LitElement {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("showPage", this._onShowPage);
|
||||
|
||||
const current = (window as any).currentPageId;
|
||||
const current = window.currentPageId;
|
||||
if (current) {
|
||||
// Wait for render
|
||||
this.updateComplete.then(() => {
|
||||
this._updateActiveState(current);
|
||||
});
|
||||
}
|
||||
|
||||
// Check if cosmetics have changed
|
||||
getCosmeticsHash().then((hash: string | null) => {
|
||||
const seenHash = localStorage.getItem(STORE_SEEN_HASH_KEY);
|
||||
this._hasNewCosmetics = hash !== null && hash !== seenHash;
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
@@ -54,34 +43,13 @@ export class DesktopNavBar extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private showHelpDot(): boolean {
|
||||
// Only show one dot at a time to prevent
|
||||
// overwhelming users.
|
||||
return getGamesPlayed() < 10 && !this._helpSeen;
|
||||
}
|
||||
|
||||
private showStoreDot(): boolean {
|
||||
return this._hasNewCosmetics && !this.showHelpDot();
|
||||
}
|
||||
|
||||
private onHelpClick = () => {
|
||||
localStorage.setItem(HELP_SEEN_KEY, "true");
|
||||
this._helpSeen = true;
|
||||
};
|
||||
|
||||
private onStoreClick = () => {
|
||||
this._hasNewCosmetics = false;
|
||||
getCosmeticsHash().then((hash: string | null) => {
|
||||
if (hash !== null) {
|
||||
localStorage.setItem(STORE_SEEN_HASH_KEY, hash);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
window.currentPageId ??= "page-play";
|
||||
const currentPage = window.currentPageId;
|
||||
|
||||
return html`
|
||||
<nav
|
||||
class="hidden lg:flex w-full bg-slate-950/70 backdrop-blur-md border-b border-white/10 items-center justify-center gap-8 py-4 shrink-0 transition-opacity z-50 relative"
|
||||
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]">
|
||||
@@ -89,7 +57,7 @@ export class DesktopNavBar extends LitElement {
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 1364 259"
|
||||
fill="currentColor"
|
||||
class="h-full w-auto drop-shadow-[0_0_10px_rgba(37,99,235,0.4)]"
|
||||
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"
|
||||
@@ -131,25 +99,44 @@ export class DesktopNavBar extends LitElement {
|
||||
class="l-header__highlightText text-center"
|
||||
></div>
|
||||
</div>
|
||||
<!-- Desktop Navigation Menu Items -->
|
||||
<button
|
||||
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
class="nav-menu-item ${currentPage === "page-play"
|
||||
? "active"
|
||||
: ""} text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
data-page="page-play"
|
||||
data-i18n="main.play"
|
||||
></button>
|
||||
<button
|
||||
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
data-page="page-news"
|
||||
data-i18n="main.news"
|
||||
></button>
|
||||
<!-- Desktop Navigation Menu Items -->
|
||||
<div class="relative">
|
||||
<button
|
||||
class="nav-menu-item ${currentPage === "page-news"
|
||||
? "active"
|
||||
: ""} text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
data-page="page-news"
|
||||
data-i18n="main.news"
|
||||
@click=${this._notifications.onNewsClick}
|
||||
></button>
|
||||
${this._notifications.showNewsDot()
|
||||
? html`
|
||||
<span
|
||||
class="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full animate-ping"
|
||||
></span>
|
||||
<span
|
||||
class="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full"
|
||||
></span>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
<div class="relative no-crazygames">
|
||||
<button
|
||||
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
class="nav-menu-item ${currentPage === "page-item-store"
|
||||
? "active"
|
||||
: ""} text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
data-page="page-item-store"
|
||||
data-i18n="main.store"
|
||||
@click=${this.onStoreClick}
|
||||
@click=${this._notifications.onStoreClick}
|
||||
></button>
|
||||
${this.showStoreDot()
|
||||
${this._notifications.showStoreDot()
|
||||
? html`
|
||||
<span
|
||||
class="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full animate-ping"
|
||||
@@ -175,9 +162,9 @@ export class DesktopNavBar extends LitElement {
|
||||
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
data-page="page-help"
|
||||
data-i18n="main.help"
|
||||
@click=${this.onHelpClick}
|
||||
@click=${this._notifications.onHelpClick}
|
||||
></button>
|
||||
${this.showHelpDot()
|
||||
${this._notifications.showHelpDot()
|
||||
? html`
|
||||
<span
|
||||
class="absolute -top-1 -right-1 w-2 h-2 bg-yellow-400 rounded-full animate-ping"
|
||||
|
||||
@@ -14,6 +14,8 @@ export class FluentSlider extends LitElement {
|
||||
@property({ type: Number }) step = 1;
|
||||
@property({ type: String }) labelKey = "";
|
||||
@property({ type: String }) disabledKey = "";
|
||||
@property({ type: Number }) defaultValue: number | undefined = undefined;
|
||||
@property({ type: String }) defaultLabelKey = "";
|
||||
|
||||
@state() private isEditing = false;
|
||||
|
||||
@@ -131,7 +133,14 @@ export class FluentSlider extends LitElement {
|
||||
>
|
||||
${this.value === 0 && this.disabledKey
|
||||
? translateText(this.disabledKey)
|
||||
: this.value}
|
||||
: this.defaultValue !== undefined &&
|
||||
this.value === this.defaultValue &&
|
||||
this.defaultLabelKey
|
||||
? html`${this.value}
|
||||
<span class="text-white/40 uppercase"
|
||||
>(${translateText(this.defaultLabelKey)})</span
|
||||
>`
|
||||
: this.value}
|
||||
</span>`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,9 +10,9 @@ export class Footer extends LitElement {
|
||||
render() {
|
||||
return html`
|
||||
<footer
|
||||
class="[.in-game_&]:hidden bg-black/60 backdrop-blur-md flex flex-col items-center justify-center gap-2 pt-[2px] pb-2 text-white/50 w-full border-t border-white/10 shrink-0 mt-auto"
|
||||
class="[.in-game_&]:hidden bg-zinc-900/90 backdrop-blur-md flex flex-col items-center justify-center gap-1 pt-1 pb-3 text-white/50 w-full border-t border-white/10 shrink-0 mt-auto relative z-50"
|
||||
>
|
||||
<div class="flex items-center justify-center gap-6 pt-2">
|
||||
<div class="flex items-center justify-center gap-4 lg:gap-6 pt-2">
|
||||
<a
|
||||
href="https://github.com/openfrontio/OpenFrontIO"
|
||||
target="_blank"
|
||||
@@ -22,7 +22,8 @@ export class Footer extends LitElement {
|
||||
<img
|
||||
src="/icons/github-mark-white.svg"
|
||||
data-i18n-alt="news.github_link"
|
||||
class="h-7 w-7 object-contain"
|
||||
class="h-6 w-6 lg:h-7 lg:w-7 object-contain pointer-events-none"
|
||||
draggable="false"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
@@ -32,7 +33,7 @@ export class Footer extends LitElement {
|
||||
class="opacity-60 hover:opacity-100 hover:scale-110 transition-all"
|
||||
>
|
||||
<svg
|
||||
class="h-7 w-7 object-contain"
|
||||
class="h-6 w-6 lg:h-7 lg:w-7 object-contain pointer-events-none"
|
||||
viewBox="0 0 24 24"
|
||||
fill="white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -43,13 +44,13 @@ export class Footer extends LitElement {
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.gg/jRpxXvG42t"
|
||||
href="https://discord.gg/openfront"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="opacity-60 hover:opacity-100 hover:scale-110 transition-all"
|
||||
>
|
||||
<svg
|
||||
class="h-7 w-7 object-contain"
|
||||
class="h-6 w-6 lg:h-7 lg:w-7 object-contain pointer-events-none"
|
||||
viewBox="0 0 24 24"
|
||||
fill="white"
|
||||
>
|
||||
@@ -67,11 +68,14 @@ export class Footer extends LitElement {
|
||||
<img
|
||||
src="/icons/wiki-logo.svg"
|
||||
data-i18n-alt="main.wiki"
|
||||
class="h-7 w-7 object-contain"
|
||||
class="h-6 w-6 lg:h-7 lg:w-7 object-contain pointer-events-none"
|
||||
draggable="false"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<div class="text-xs mt-2 flex items-center justify-center gap-4">
|
||||
<div
|
||||
class="text-xs mt-1 lg:mt-2 flex items-center justify-center gap-4 px-4"
|
||||
>
|
||||
<a
|
||||
href="/terms-of-service.html"
|
||||
data-i18n="main.terms_of_service"
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
import {
|
||||
LitElement,
|
||||
SVGTemplateResult,
|
||||
TemplateResult,
|
||||
html,
|
||||
nothing,
|
||||
svg,
|
||||
} from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import {
|
||||
Difficulty,
|
||||
Duos,
|
||||
GameMapType,
|
||||
GameMode,
|
||||
HumansVsNations,
|
||||
Quads,
|
||||
Trios,
|
||||
UnitType,
|
||||
} from "../../core/game/Game";
|
||||
import { TeamCountConfig } from "../../core/Schemas";
|
||||
import { translateText } from "../Utils";
|
||||
import "./Difficulties";
|
||||
import "./FluentSlider";
|
||||
import "./map/MapPicker";
|
||||
|
||||
const ACTIVE_CARD =
|
||||
"bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]";
|
||||
const INACTIVE_CARD =
|
||||
"bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20";
|
||||
|
||||
const DISABLED_CARD =
|
||||
"w-full rounded-xl border transition-all duration-200 opacity-30 grayscale cursor-not-allowed bg-white/5 border-white/5";
|
||||
|
||||
function cardClass(active: boolean, extra = ""): string {
|
||||
return `w-full rounded-xl border cursor-pointer transition-all duration-200 active:scale-95 ${extra} ${active ? ACTIVE_CARD : INACTIVE_CARD}`;
|
||||
}
|
||||
|
||||
const CARD_LABEL_CLASS =
|
||||
"text-xs uppercase font-bold tracking-wider leading-tight break-words hyphens-auto";
|
||||
|
||||
const DIFFICULTY_OPTIONS = Object.entries(Difficulty).filter(([key]) =>
|
||||
isNaN(Number(key)),
|
||||
) as Array<[string, Difficulty]>;
|
||||
const TEAM_COUNT_OPTIONS: TeamCountConfig[] = [
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
Quads,
|
||||
Trios,
|
||||
Duos,
|
||||
HumansVsNations,
|
||||
];
|
||||
|
||||
function stateTextClass(active: boolean): string {
|
||||
return active ? "text-white" : "text-white/60";
|
||||
}
|
||||
|
||||
function renderTextCardButton(
|
||||
label: string,
|
||||
active: boolean,
|
||||
onClick: () => void,
|
||||
cardExtraClass: string,
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<button class="${cardClass(active, cardExtraClass)}" @click=${onClick}>
|
||||
<span class="${CARD_LABEL_CLASS} ${stateTextClass(active)}">
|
||||
${label}
|
||||
</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSection(
|
||||
iconSvg: SVGTemplateResult,
|
||||
colorClass: string,
|
||||
bgClass: string,
|
||||
titleKey: string,
|
||||
content: TemplateResult | TemplateResult[],
|
||||
sectionClass = "space-y-6",
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<section class=${sectionClass}>
|
||||
${renderSectionHeader(iconSvg, colorClass, bgClass, titleKey)} ${content}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
const unitOptions: { type: UnitType; translationKey: string }[] = [
|
||||
{ type: UnitType.City, translationKey: "unit_type.city" },
|
||||
{ type: UnitType.DefensePost, translationKey: "unit_type.defense_post" },
|
||||
{ type: UnitType.Port, translationKey: "unit_type.port" },
|
||||
{ type: UnitType.Warship, translationKey: "unit_type.warship" },
|
||||
{ type: UnitType.TransportShip, translationKey: "unit_type.boat" },
|
||||
{ type: UnitType.MissileSilo, translationKey: "unit_type.missile_silo" },
|
||||
{ type: UnitType.SAMLauncher, translationKey: "unit_type.sam_launcher" },
|
||||
{ type: UnitType.AtomBomb, translationKey: "unit_type.atom_bomb" },
|
||||
{ type: UnitType.HydrogenBomb, translationKey: "unit_type.hydrogen_bomb" },
|
||||
{ type: UnitType.MIRV, translationKey: "unit_type.mirv" },
|
||||
{ type: UnitType.Factory, translationKey: "unit_type.factory" },
|
||||
];
|
||||
|
||||
const MAP_ICON = svg`<path
|
||||
d="M21.731 2.269a2.625 2.625 0 00-3.712 0l-1.157 1.157 3.712 3.712 1.157-1.157a2.625 2.625 0 000-3.712zM19.513 8.199l-3.712-3.712-12.15 12.15a5.25 5.25 0 00-1.32 2.214l-.8 2.685a.75.75 0 00.933.933l2.685-.8a5.25 5.25 0 002.214-1.32L19.513 8.2z"
|
||||
/>`;
|
||||
|
||||
const DIFFICULTY_ICON = svg`<path
|
||||
fill-rule="evenodd"
|
||||
d="M12.97 3.97a.75.75 0 011.06 0l7.5 7.5a.75.75 0 010 1.06l-7.5 7.5a.75.75 0 11-1.06-1.06l6.22-6.22H3a.75.75 0 010-1.5h16.19l-6.22-6.22a.75.75 0 010-1.06z"
|
||||
clip-rule="evenodd"
|
||||
/>`;
|
||||
|
||||
const MODE_ICON = svg`<path
|
||||
d="M11.25 4.533A9.707 9.707 0 006 3a9.735 9.735 0 00-3.25.555.75.75 0 00-.5.707v14.25a.75.75 0 001 .707A8.237 8.237 0 016 18.75c1.995 0 3.823.707 5.25 1.886V4.533zM12.75 20.636A8.214 8.214 0 0118 18.75c.966 0 1.89.166 2.75.47a.75.75 0 001-.708V4.262a.75.75 0 00-.5-.707A9.735 9.735 0 0018 3a9.707 9.707 0 00-5.25 1.533v16.103z"
|
||||
/>`;
|
||||
|
||||
const OPTIONS_ICON = svg`<path
|
||||
fill-rule="evenodd"
|
||||
d="M11.078 2.25c-.917 0-1.699.663-1.85 1.567L9.05 4.889c-.02.12-.115.26-.297.348a7.493 7.493 0 00-.986.57c-.166.115-.334.126-.45.083L6.3 5.508a1.875 1.875 0 00-2.282.819l-.922 1.597a1.875 1.875 0 00.432 2.385l.84.692c.095.078.17.229.154.43a7.598 7.598 0 000 1.139c.015.2-.059.352-.153.43l-.841.692a1.875 1.875 0 00-.432 2.385l.922 1.597a1.875 1.875 0 002.282.818l1.019-.382c.115-.043.283-.031.45.082.312.214.641.405.985.57.182.088.277.228.297.35l.178 1.071c.151.904.933 1.567 1.85 1.567h1.844c.916 0 1.699-.663 1.85-1.567l.178-1.072c.02-.12.114-.26.297-.349.344-.165.673-.356.985-.57.167-.114.335-.125.45-.082l1.02.382a1.875 1.875 0 002.28-.819l.922-1.597a1.875 1.875 0 00-.432-2.385l-.84-.692c-.095-.078-.17-.229-.154-.43a7.614 7.614 0 000-1.139c-.016-.2.059-.352.153-.43l.84-.692c.708-.582.891-1.59.433-2.385l-.922-1.597a1.875 1.875 0 00-2.282-.818l-1.02.382c-.114.043-.282.031-.449-.083a7.49 7.49 0 00-.985-.57c-.183-.087-.277-.227-.297-.348l-.179-1.072a1.875 1.875 0 00-1.85-1.567h-1.843zM12 15.75a3.75 3.75 0 100-7.5 3.75 3.75 0 000 7.5z"
|
||||
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"
|
||||
clip-rule="evenodd"
|
||||
/>`;
|
||||
|
||||
function renderSectionHeader(
|
||||
iconSvg: SVGTemplateResult,
|
||||
colorClass: string,
|
||||
bgClass: string,
|
||||
titleKey: string,
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<div class="flex items-center gap-4 pb-2 border-b border-white/10">
|
||||
<div
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center ${bgClass} ${colorClass}"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
${iconSvg}
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-white uppercase tracking-wider">
|
||||
${translateText(titleKey)}
|
||||
</h3>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export interface ToggleOptionConfig {
|
||||
labelKey: string;
|
||||
checked: boolean;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export interface GameConfigSettingsData {
|
||||
map: {
|
||||
selected: GameMapType;
|
||||
useRandom: boolean;
|
||||
randomMapDivider?: boolean;
|
||||
showMedals?: boolean;
|
||||
mapWins?: Map<GameMapType, Set<Difficulty>>;
|
||||
};
|
||||
difficulty: {
|
||||
selected: Difficulty;
|
||||
disabled: boolean;
|
||||
};
|
||||
gameMode: {
|
||||
selected: GameMode;
|
||||
};
|
||||
teamCount: {
|
||||
selected: TeamCountConfig;
|
||||
};
|
||||
options: {
|
||||
titleKey: string;
|
||||
bots: {
|
||||
value: number;
|
||||
labelKey: string;
|
||||
disabledKey: string;
|
||||
};
|
||||
nations?: {
|
||||
value: number;
|
||||
defaultValue?: number;
|
||||
labelKey: string;
|
||||
disabledKey: string;
|
||||
hidden?: boolean;
|
||||
};
|
||||
toggles: ToggleOptionConfig[];
|
||||
inputCards: TemplateResult[];
|
||||
};
|
||||
unitTypes: {
|
||||
titleKey: string;
|
||||
disabledUnits: UnitType[];
|
||||
};
|
||||
}
|
||||
|
||||
@customElement("game-config-settings")
|
||||
export class GameConfigSettings extends LitElement {
|
||||
@property({ attribute: false }) settings?: GameConfigSettingsData;
|
||||
@property({ attribute: false }) sectionGapClass = "space-y-6";
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private emit<T>(name: string, detail: T) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent(name, {
|
||||
detail,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private handleSelectMap = (map: GameMapType) => {
|
||||
this.emit("map-selected", { map });
|
||||
};
|
||||
|
||||
private handleSelectRandom = () => {
|
||||
this.emit("random-map-selected", {});
|
||||
};
|
||||
|
||||
private handleDifficultySelect = (difficulty: Difficulty) => {
|
||||
this.emit("difficulty-selected", { difficulty });
|
||||
};
|
||||
|
||||
private handleGameModeSelect = (mode: GameMode) => {
|
||||
this.emit("game-mode-selected", { mode });
|
||||
};
|
||||
|
||||
private handleTeamCountSelect = (count: TeamCountConfig) => {
|
||||
this.emit("team-count-selected", { count });
|
||||
};
|
||||
|
||||
private handleOptionToggle = (toggle: ToggleOptionConfig) => {
|
||||
this.emit("option-toggle-changed", {
|
||||
labelKey: toggle.labelKey,
|
||||
checked: !toggle.checked,
|
||||
});
|
||||
};
|
||||
|
||||
private handleBotsChanged = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{ value: number }>;
|
||||
this.emit("bots-changed", customEvent.detail);
|
||||
};
|
||||
|
||||
private handleNationsChanged = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{ value: number }>;
|
||||
this.emit("nations-changed", customEvent.detail);
|
||||
};
|
||||
|
||||
private handleUnitToggle = (unit: UnitType, checked: boolean) => {
|
||||
this.emit("unit-toggle-changed", { unit, checked });
|
||||
};
|
||||
|
||||
private renderOptionToggle(toggle: ToggleOptionConfig): TemplateResult {
|
||||
if (toggle.hidden) return html``;
|
||||
|
||||
return renderTextCardButton(
|
||||
translateText(toggle.labelKey),
|
||||
toggle.checked,
|
||||
() => this.handleOptionToggle(toggle),
|
||||
"p-4 text-center",
|
||||
);
|
||||
}
|
||||
|
||||
private renderUnitTypeOptions(disabledUnits: UnitType[]): TemplateResult[] {
|
||||
return unitOptions.map(({ type, translationKey }) => {
|
||||
const isEnabled = !disabledUnits.includes(type);
|
||||
return html`
|
||||
<button
|
||||
class="${cardClass(isEnabled, "p-4 text-center")}"
|
||||
aria-pressed=${isEnabled}
|
||||
@click=${() => this.handleUnitToggle(type, isEnabled)}
|
||||
>
|
||||
<span class="${CARD_LABEL_CLASS} ${stateTextClass(isEnabled)}">
|
||||
${translateText(translationKey)}
|
||||
</span>
|
||||
</button>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.settings) return nothing;
|
||||
const settings = this.settings;
|
||||
|
||||
return html`
|
||||
<div class=${this.sectionGapClass}>
|
||||
${renderSection(
|
||||
MAP_ICON,
|
||||
"text-blue-400",
|
||||
"bg-blue-500/20",
|
||||
"map.map",
|
||||
html`<map-picker
|
||||
.selectedMap=${settings.map.selected}
|
||||
.useRandomMap=${settings.map.useRandom}
|
||||
.randomMapDivider=${settings.map.randomMapDivider ?? false}
|
||||
.showMedals=${settings.map.showMedals ?? false}
|
||||
.mapWins=${settings.map.mapWins ?? new Map()}
|
||||
.onSelectMap=${this.handleSelectMap}
|
||||
.onSelectRandom=${this.handleSelectRandom}
|
||||
></map-picker>`,
|
||||
)}
|
||||
${renderSection(
|
||||
DIFFICULTY_ICON,
|
||||
"text-green-400",
|
||||
"bg-green-500/20",
|
||||
"difficulty.difficulty",
|
||||
html`
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
${DIFFICULTY_OPTIONS.map(([key, value]) => {
|
||||
const isSelected = settings.difficulty.selected === value;
|
||||
const isDisabled = settings.difficulty.disabled;
|
||||
return html`
|
||||
<button
|
||||
?disabled=${isDisabled}
|
||||
@click=${() =>
|
||||
!isDisabled &&
|
||||
this.handleDifficultySelect(value as Difficulty)}
|
||||
class="${isDisabled
|
||||
? `${DISABLED_CARD} flex flex-col items-center p-4 gap-3`
|
||||
: cardClass(
|
||||
isSelected,
|
||||
"flex flex-col items-center p-4 gap-3",
|
||||
)}"
|
||||
>
|
||||
<difficulty-display
|
||||
.difficultyKey=${key}
|
||||
class="transform scale-125 origin-center ${isDisabled
|
||||
? "pointer-events-none"
|
||||
: ""}"
|
||||
></difficulty-display>
|
||||
<span
|
||||
class="${CARD_LABEL_CLASS} text-center mt-1 text-white"
|
||||
>
|
||||
${translateText(`difficulty.${key.toLowerCase()}`)}
|
||||
</span>
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
${renderSection(
|
||||
MODE_ICON,
|
||||
"text-purple-400",
|
||||
"bg-purple-500/20",
|
||||
"host_modal.mode",
|
||||
html`
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
${[GameMode.FFA, GameMode.Team].map((mode) => {
|
||||
const isSelected = settings.gameMode.selected === mode;
|
||||
return html`
|
||||
<button
|
||||
class="${cardClass(isSelected, "py-6 text-center")}"
|
||||
@click=${() => this.handleGameModeSelect(mode)}
|
||||
>
|
||||
<span
|
||||
class="text-sm font-bold text-white uppercase tracking-widest"
|
||||
>
|
||||
${mode === GameMode.FFA
|
||||
? translateText("game_mode.ffa")
|
||||
: translateText("game_mode.teams")}
|
||||
</span>
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
${settings.gameMode.selected === GameMode.FFA
|
||||
? nothing
|
||||
: html`
|
||||
<section class="space-y-6">
|
||||
<div
|
||||
class="text-xs font-bold text-white/40 uppercase tracking-widest mb-4 pl-2"
|
||||
>
|
||||
${translateText("host_modal.team_count")}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
${TEAM_COUNT_OPTIONS.map((o) => {
|
||||
const isSelected = settings.teamCount.selected === o;
|
||||
return html`
|
||||
<button
|
||||
class="${cardClass(
|
||||
isSelected,
|
||||
"px-4 py-3 text-center",
|
||||
)}"
|
||||
@click=${() => this.handleTeamCountSelect(o)}
|
||||
>
|
||||
<span class="${CARD_LABEL_CLASS} text-white">
|
||||
${typeof o === "string"
|
||||
? o === HumansVsNations
|
||||
? translateText("public_lobby.teams_hvn")
|
||||
: translateText(`host_modal.teams_${o}`)
|
||||
: translateText("public_lobby.teams", { num: o })}
|
||||
</span>
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
`}
|
||||
${renderSection(
|
||||
OPTIONS_ICON,
|
||||
"text-orange-400",
|
||||
"bg-orange-500/20",
|
||||
settings.options.titleKey,
|
||||
html`
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div
|
||||
class="col-span-2 rounded-xl p-4 flex flex-col justify-center border transition-all duration-200 ${settings
|
||||
.options.bots.value > 0
|
||||
? ACTIVE_CARD
|
||||
: INACTIVE_CARD}"
|
||||
>
|
||||
<fluent-slider
|
||||
min="0"
|
||||
max="400"
|
||||
step="1"
|
||||
.value=${settings.options.bots.value}
|
||||
labelKey=${settings.options.bots.labelKey}
|
||||
disabledKey=${settings.options.bots.disabledKey}
|
||||
@value-changed=${this.handleBotsChanged}
|
||||
></fluent-slider>
|
||||
</div>
|
||||
|
||||
${settings.options.nations && !settings.options.nations.hidden
|
||||
? html`<div
|
||||
class="col-span-2 rounded-xl p-4 flex flex-col justify-center border transition-all duration-200 ${settings
|
||||
.options.nations.value > 0
|
||||
? ACTIVE_CARD
|
||||
: INACTIVE_CARD}"
|
||||
>
|
||||
<fluent-slider
|
||||
min="0"
|
||||
max="400"
|
||||
step="1"
|
||||
.value=${settings.options.nations.value}
|
||||
.defaultValue=${settings.options.nations.defaultValue}
|
||||
defaultLabelKey="common.map_default"
|
||||
labelKey=${settings.options.nations.labelKey}
|
||||
disabledKey=${settings.options.nations.disabledKey}
|
||||
@value-changed=${this.handleNationsChanged}
|
||||
></fluent-slider>
|
||||
</div>`
|
||||
: nothing}
|
||||
${settings.options.toggles.map((toggle) =>
|
||||
this.renderOptionToggle(toggle),
|
||||
)}
|
||||
${settings.options.inputCards}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
${renderSection(
|
||||
ENABLES_ICON,
|
||||
"text-teal-400",
|
||||
"bg-teal-500/20",
|
||||
settings.unitTypes.titleKey,
|
||||
html`
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
${this.renderUnitTypeOptions(settings.unitTypes.disabledUnits)}
|
||||
</div>
|
||||
`,
|
||||
"space-y-6 pb-6",
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,11 @@ import {
|
||||
Team,
|
||||
Trios,
|
||||
} from "../../core/game/Game";
|
||||
import { getCompactMapNationCount } from "../../core/game/NationCreation";
|
||||
import { assignTeamsLobbyPreview } from "../../core/game/TeamAssignment";
|
||||
import { UserSettings } from "../../core/game/UserSettings";
|
||||
import { ClientInfo, TeamCountConfig } from "../../core/Schemas";
|
||||
import { createRandomName } from "../../core/Util";
|
||||
import { translateText } from "../Utils";
|
||||
import { getTranslatedPlayerTeamLabel, translateText } from "../Utils";
|
||||
|
||||
export interface TeamPreviewData {
|
||||
team: Team;
|
||||
@@ -36,8 +35,6 @@ export class LobbyTeamView extends LitElement {
|
||||
@property({ attribute: "team-count" }) teamCount: TeamCountConfig = 2;
|
||||
@property({ type: Function }) onKickPlayer?: (clientID: string) => void;
|
||||
@property({ type: Number }) nationCount: number = 0;
|
||||
@property({ type: Boolean }) disableNations: boolean = false;
|
||||
@property({ type: Boolean }) isCompactMap: boolean = false;
|
||||
|
||||
private theme: PastelTheme = new PastelTheme();
|
||||
@state() private showTeamColors: boolean = false;
|
||||
@@ -45,14 +42,12 @@ export class LobbyTeamView extends LitElement {
|
||||
|
||||
willUpdate(changedProperties: Map<string, any>) {
|
||||
// Recompute team preview when relevant properties change
|
||||
// clients is 'changed' every 1s from pollPlayers, chose to not compare for actual change
|
||||
// clients is updated from WebSocket lobby_info events
|
||||
if (
|
||||
changedProperties.has("gameMode") ||
|
||||
changedProperties.has("clients") ||
|
||||
changedProperties.has("teamCount") ||
|
||||
changedProperties.has("nationCount") ||
|
||||
changedProperties.has("disableNations") ||
|
||||
changedProperties.has("isCompactMap")
|
||||
changedProperties.has("nationCount")
|
||||
) {
|
||||
const teamsList = this.getTeamList();
|
||||
this.computeTeamPreview(teamsList);
|
||||
@@ -72,8 +67,8 @@ export class LobbyTeamView extends LitElement {
|
||||
? translateText("host_modal.player")
|
||||
: translateText("host_modal.players")}
|
||||
<span style="margin: 0 8px;">•</span>
|
||||
${this.getEffectiveNationCount()}
|
||||
${this.getEffectiveNationCount() === 1
|
||||
${this.nationCount}
|
||||
${this.nationCount === 1
|
||||
? translateText("host_modal.nation_player")
|
||||
: translateText("host_modal.nation_players")}
|
||||
</div>
|
||||
@@ -182,17 +177,18 @@ export class LobbyTeamView extends LitElement {
|
||||
}
|
||||
|
||||
private renderTeamCard(preview: TeamPreviewData, isEmpty: boolean = false) {
|
||||
const effectiveNationCount = this.getEffectiveNationCount();
|
||||
const displayCount =
|
||||
preview.team === ColoredTeams.Nations
|
||||
? effectiveNationCount
|
||||
? this.nationCount
|
||||
: preview.players.length;
|
||||
|
||||
const maxTeamSize =
|
||||
preview.team === ColoredTeams.Nations
|
||||
? effectiveNationCount
|
||||
? this.nationCount
|
||||
: this.teamMaxSize;
|
||||
|
||||
const teamLabel = getTranslatedPlayerTeamLabel(preview.team);
|
||||
|
||||
return html`
|
||||
<div class="bg-gray-800 border border-gray-700 rounded-xl flex flex-col">
|
||||
<div
|
||||
@@ -204,7 +200,7 @@ export class LobbyTeamView extends LitElement {
|
||||
style="--bg:${this.teamHeaderColor(preview.team)};"
|
||||
></span>`
|
||||
: null}
|
||||
<span class="truncate">${preview.team}</span>
|
||||
<span class="truncate">${teamLabel}</span>
|
||||
<span class="text-white/90">${displayCount}/${maxTeamSize}</span>
|
||||
</div>
|
||||
<div class="p-2 ${isEmpty ? "" : "flex flex-col gap-1.5"}">
|
||||
@@ -249,7 +245,7 @@ export class LobbyTeamView extends LitElement {
|
||||
|
||||
private getTeamList(): Team[] {
|
||||
if (this.gameMode !== GameMode.Team) return [];
|
||||
const playerCount = this.clients.length + this.getEffectiveNationCount();
|
||||
const playerCount = this.clients.length + this.nationCount;
|
||||
const config = this.teamCount;
|
||||
|
||||
if (config === HumansVsNations) {
|
||||
@@ -313,7 +309,7 @@ export class LobbyTeamView extends LitElement {
|
||||
const assignment = assignTeamsLobbyPreview(
|
||||
players,
|
||||
teams,
|
||||
this.getEffectiveNationCount(),
|
||||
this.nationCount,
|
||||
);
|
||||
const buckets = new Map<Team, ClientInfo[]>();
|
||||
for (const t of teams) buckets.set(t, []);
|
||||
@@ -337,9 +333,7 @@ export class LobbyTeamView extends LitElement {
|
||||
// Fallback: divide players across teams; guard against 0 and empty lobbies
|
||||
this.teamMaxSize = Math.max(
|
||||
1,
|
||||
Math.ceil(
|
||||
(this.clients.length + this.getEffectiveNationCount()) / teams.length,
|
||||
),
|
||||
Math.ceil((this.clients.length + this.nationCount) / teams.length),
|
||||
);
|
||||
}
|
||||
this.teamPreview = teams.map((t) => ({
|
||||
@@ -348,22 +342,6 @@ export class LobbyTeamView extends LitElement {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the effective nation count for display purposes.
|
||||
* In HumansVsNations mode, this equals the number of human players.
|
||||
* For compact maps, only 25% of nations are used.
|
||||
* Otherwise, it uses the manifest nation count (or 0 if nations are disabled).
|
||||
*/
|
||||
private getEffectiveNationCount(): number {
|
||||
if (this.disableNations) {
|
||||
return 0;
|
||||
}
|
||||
if (this.gameMode === GameMode.Team && this.teamCount === HumansVsNations) {
|
||||
return this.clients.length;
|
||||
}
|
||||
return getCompactMapNationCount(this.nationCount, this.isCompactMap);
|
||||
}
|
||||
|
||||
private displayUsername(client: ClientInfo): string {
|
||||
if (!this.userSettings.anonymousNames()) {
|
||||
return client.username;
|
||||
|
||||
@@ -19,10 +19,10 @@ export class MainLayout extends LitElement {
|
||||
render() {
|
||||
return html`
|
||||
<main
|
||||
class="relative [.in-game_&]:hidden flex flex-col flex-1 overflow-hidden w-full px-[clamp(1.5rem,3vw,3rem)] pt-[clamp(0.75rem,1.5vw,1.5rem)] pb-[clamp(0.75rem,1.5vw,1.5rem)]"
|
||||
class="relative [.in-game_&]:hidden flex flex-col flex-1 overflow-hidden w-full px-0 lg:px-[clamp(1.5rem,3vw,3rem)] pt-0 lg:pt-[clamp(0.75rem,1.5vw,1.5rem)] pb-0 lg:pb-[clamp(0.75rem,1.5vw,1.5rem)]"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-[20cm] mx-auto flex flex-col flex-1 gap-[clamp(1.5rem,3vw,3rem)] overflow-y-auto overflow-x-hidden [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden lg:[scrollbar-width:auto] lg:[-ms-overflow-style:auto] lg:[&::-webkit-scrollbar]:block"
|
||||
class="w-full lg:max-w-[20cm] mx-auto flex flex-col flex-1 gap-0 lg:gap-[clamp(1.5rem,3vw,3rem)] overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
${this._initialChildren}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { html, LitElement, TemplateResult } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { NavNotificationsController } from "./NavNotificationsController";
|
||||
|
||||
@customElement("mobile-nav-bar")
|
||||
export class MobileNavBar extends LitElement {
|
||||
private _notifications = new NavNotificationsController(this);
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
@@ -11,7 +14,7 @@ export class MobileNavBar extends LitElement {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("showPage", this._onShowPage);
|
||||
|
||||
const current = (window as any).currentPageId;
|
||||
const current = window.currentPageId;
|
||||
if (current) {
|
||||
this.updateComplete.then(() => {
|
||||
this._updateActiveState(current);
|
||||
@@ -31,15 +34,28 @@ export class MobileNavBar extends LitElement {
|
||||
|
||||
private _updateActiveState(pageId: string) {
|
||||
this.querySelectorAll(".nav-menu-item").forEach((el) => {
|
||||
const inner = el.querySelector("button");
|
||||
if ((el as HTMLElement).dataset.page === pageId) {
|
||||
el.classList.add("active");
|
||||
inner?.classList.add("active");
|
||||
} else {
|
||||
el.classList.remove("active");
|
||||
inner?.classList.remove("active");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _renderDot(color: string): TemplateResult {
|
||||
return html`<span class="relative ml-2 shrink-0 -mt-2 w-2 h-2">
|
||||
<span class="absolute inset-0 ${color} rounded-full animate-ping"></span>
|
||||
<span class="absolute inset-0 ${color} rounded-full"></span>
|
||||
</span>`;
|
||||
}
|
||||
|
||||
render() {
|
||||
window.currentPageId ??= "page-play";
|
||||
const currentPage = window.currentPageId;
|
||||
|
||||
return html`
|
||||
<!-- Border Segments (Custom right border with gap for button) -->
|
||||
<div
|
||||
@@ -52,7 +68,7 @@ export class MobileNavBar extends LitElement {
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="flex-1 w-full flex flex-col justify-start overflow-y-auto md:pt-[clamp(1rem,3vh,4rem)] md:pb-[clamp(0.5rem,2vh,2rem)] md:px-[clamp(1rem,1.5vw,2rem)] p-5 gap-[clamp(1rem,3vh,3rem)]"
|
||||
class="flex-1 w-full flex flex-col justify-start overflow-y-auto lg:pt-[clamp(1rem,3vh,4rem)] lg:pb-[clamp(0.5rem,2vh,2rem)] lg:px-[clamp(1rem,1.5vw,2rem)] p-5 gap-[clamp(1rem,3vh,3rem)]"
|
||||
>
|
||||
<!-- Logo + Menu -->
|
||||
<div
|
||||
@@ -110,30 +126,43 @@ export class MobileNavBar extends LitElement {
|
||||
</div>
|
||||
<!-- Mobile Navigation Menu Items -->
|
||||
<button
|
||||
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
|
||||
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] ${currentPage ===
|
||||
"page-play"
|
||||
? "active"
|
||||
: ""}"
|
||||
data-page="page-play"
|
||||
data-i18n="main.play"
|
||||
></button>
|
||||
<button
|
||||
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
|
||||
<div
|
||||
class="nav-menu-item flex items-center w-full cursor-pointer"
|
||||
data-page="page-news"
|
||||
data-i18n="main.news"
|
||||
></button>
|
||||
@click=${this._notifications.onNewsClick}
|
||||
>
|
||||
<button
|
||||
class="block text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
|
||||
data-i18n="main.news"
|
||||
></button>
|
||||
${this._notifications.showNewsDot()
|
||||
? this._renderDot("bg-red-500")
|
||||
: ""}
|
||||
</div>
|
||||
<button
|
||||
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
|
||||
data-page="page-leaderboard"
|
||||
data-i18n="main.leaderboard"
|
||||
></button>
|
||||
<div class="relative no-crazygames">
|
||||
<div
|
||||
class="no-crazygames nav-menu-item flex items-center w-full cursor-pointer"
|
||||
data-page="page-item-store"
|
||||
@click=${this._notifications.onStoreClick}
|
||||
>
|
||||
<button
|
||||
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
|
||||
data-page="page-item-store"
|
||||
class="block text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
|
||||
data-i18n="main.store"
|
||||
></button>
|
||||
<span
|
||||
class="absolute top-[45%] -translate-y-1/2 right-4 bg-gradient-to-br from-red-600 to-red-700 text-white text-[10px] font-black tracking-wider px-2.5 py-1 rounded rotate-12 shadow-lg shadow-red-600/50 animate-pulse pointer-events-none"
|
||||
data-i18n="main.store_new_badge"
|
||||
></span>
|
||||
${this._notifications.showStoreDot()
|
||||
? this._renderDot("bg-red-500")
|
||||
: ""}
|
||||
</div>
|
||||
<button
|
||||
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
|
||||
@@ -145,11 +174,19 @@ export class MobileNavBar extends LitElement {
|
||||
data-page="page-account"
|
||||
data-i18n="main.account"
|
||||
></button>
|
||||
<button
|
||||
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
|
||||
<div
|
||||
class="nav-menu-item flex items-center w-full cursor-pointer"
|
||||
data-page="page-help"
|
||||
data-i18n="main.help"
|
||||
></button>
|
||||
@click=${this._notifications.onHelpClick}
|
||||
>
|
||||
<button
|
||||
class="block text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
|
||||
data-i18n="main.help"
|
||||
></button>
|
||||
${this._notifications.showHelpDot()
|
||||
? this._renderDot("bg-yellow-400")
|
||||
: ""}
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col w-full mt-auto [.in-game_&]:hidden items-end justify-end pt-4 border-t border-white/10"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { ReactiveController, ReactiveControllerHost } from "lit";
|
||||
import version from "resources/version.txt?raw";
|
||||
import { getCosmeticsHash } from "../Cosmetics";
|
||||
import { getGamesPlayed } from "../Utils";
|
||||
|
||||
const HELP_SEEN_KEY = "helpSeen";
|
||||
const STORE_SEEN_HASH_KEY = "storeSeenHash";
|
||||
const NEWS_SEEN_VERSION_KEY = "newsSeenVersion";
|
||||
|
||||
export class NavNotificationsController implements ReactiveController {
|
||||
private host: ReactiveControllerHost;
|
||||
|
||||
private _helpSeen = localStorage.getItem(HELP_SEEN_KEY) === "true";
|
||||
private _hasNewCosmetics = false;
|
||||
private _hasNewVersion = false;
|
||||
|
||||
private get normalizedVersion(): string {
|
||||
const trimmed = version.trim();
|
||||
return trimmed.startsWith("v") ? trimmed : `v${trimmed}`;
|
||||
}
|
||||
|
||||
constructor(host: ReactiveControllerHost) {
|
||||
this.host = host;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
hostConnected(): void {
|
||||
// Check if cosmetics have changed
|
||||
getCosmeticsHash()
|
||||
.then((hash: string | null) => {
|
||||
const seenHash = localStorage.getItem(STORE_SEEN_HASH_KEY);
|
||||
this._hasNewCosmetics = hash !== null && hash !== seenHash;
|
||||
this.host.requestUpdate();
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Check if version has changed
|
||||
const currentVersion = this.normalizedVersion;
|
||||
const seenVersion = localStorage.getItem(NEWS_SEEN_VERSION_KEY);
|
||||
this._hasNewVersion =
|
||||
seenVersion !== null && seenVersion !== currentVersion;
|
||||
if (seenVersion === null) {
|
||||
localStorage.setItem(NEWS_SEEN_VERSION_KEY, currentVersion);
|
||||
}
|
||||
}
|
||||
|
||||
hostDisconnected(): void {}
|
||||
|
||||
// Only show one dot at a time to prevent
|
||||
// overwhelming users. Priority: News > Store > Help.
|
||||
showNewsDot(): boolean {
|
||||
return this._hasNewVersion;
|
||||
}
|
||||
|
||||
showStoreDot(): boolean {
|
||||
return this._hasNewCosmetics && !this.showNewsDot();
|
||||
}
|
||||
|
||||
showHelpDot(): boolean {
|
||||
return (
|
||||
getGamesPlayed() < 10 &&
|
||||
!this._helpSeen &&
|
||||
!this.showNewsDot() &&
|
||||
!this.showStoreDot()
|
||||
);
|
||||
}
|
||||
|
||||
onNewsClick = (): void => {
|
||||
this._hasNewVersion = false;
|
||||
localStorage.setItem(NEWS_SEEN_VERSION_KEY, this.normalizedVersion);
|
||||
this.host.requestUpdate();
|
||||
};
|
||||
|
||||
onStoreClick = (): void => {
|
||||
this._hasNewCosmetics = false;
|
||||
getCosmeticsHash()
|
||||
.then((hash: string | null) => {
|
||||
if (hash !== null) {
|
||||
localStorage.setItem(STORE_SEEN_HASH_KEY, hash);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
this.host.requestUpdate();
|
||||
};
|
||||
|
||||
onHelpClick = (): void => {
|
||||
localStorage.setItem(HELP_SEEN_KEY, "true");
|
||||
this._helpSeen = true;
|
||||
this.host.requestUpdate();
|
||||
};
|
||||
}
|
||||
@@ -169,7 +169,8 @@ export function renderPatternPreview(
|
||||
return html`<img
|
||||
src="${generatePreviewDataUrl(pattern, width, height)}"
|
||||
alt="Pattern preview"
|
||||
class="w-full h-full object-contain [image-rendering:pixelated]"
|
||||
class="w-full h-full object-contain [image-rendering:pixelated] pointer-events-none"
|
||||
draggable="false"
|
||||
/>`;
|
||||
}
|
||||
|
||||
|
||||
+110
-111
@@ -11,136 +11,135 @@ export class PlayPage extends LitElement {
|
||||
return html`
|
||||
<div
|
||||
id="page-play"
|
||||
class="flex flex-col gap-2 w-full max-w-6xl mx-auto px-0 sm:px-4 transition-all duration-300 my-auto min-h-0"
|
||||
class="flex flex-col gap-2 w-full px-0 lg:px-4 lg:my-auto min-h-0"
|
||||
>
|
||||
<token-login class="absolute"></token-login>
|
||||
|
||||
<!-- Header / Identity Section -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-2 lg:gap-6 w-full">
|
||||
<!-- Mobile: Fixed top bar -->
|
||||
<div
|
||||
class="lg:hidden fixed left-0 right-0 top-0 z-40 pt-[env(safe-area-inset-top)] bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] border-b border-white/10"
|
||||
>
|
||||
<div
|
||||
class="lg:col-span-9 flex flex-row flex-nowrap gap-x-2 h-[60px] items-center bg-slate-900/80 backdrop-blur-md p-3 rounded-xl relative z-20 text-sm sm:text-base shrink-0"
|
||||
class="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center h-14 px-2 gap-2"
|
||||
>
|
||||
<!-- Flag -->
|
||||
<div
|
||||
class="h-[40px] sm:h-[50px] shrink-0 aspect-[4/3] flex items-center justify-center lg:hidden"
|
||||
<button
|
||||
id="hamburger-btn"
|
||||
class="col-start-1 justify-self-start h-10 shrink-0 aspect-[4/3] flex text-white/90 rounded-md items-center justify-center transition-colors"
|
||||
data-i18n-aria-label="main.menu"
|
||||
aria-expanded="false"
|
||||
aria-controls="sidebar-menu"
|
||||
aria-haspopup="dialog"
|
||||
data-i18n-title="main.menu"
|
||||
>
|
||||
<!-- Hamburger (Mobile) -->
|
||||
<button
|
||||
id="hamburger-btn"
|
||||
class="lg:hidden flex w-full h-full bg-slate-800/40 text-white/90 hover:bg-slate-700/40 p-0 rounded-md items-center justify-center cursor-pointer transition-all duration-200"
|
||||
data-i18n-aria-label="main.menu"
|
||||
aria-expanded="false"
|
||||
aria-controls="sidebar-menu"
|
||||
aria-haspopup="dialog"
|
||||
data-i18n-title="main.menu"
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="size-8"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-8 h-8"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="col-start-2 flex items-center justify-center text-[#2563eb] 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>
|
||||
</div>
|
||||
|
||||
<!-- Username -->
|
||||
<div class="flex-1 min-w-0 h-[40px] sm:h-[50px] flex items-center">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="col-start-3 justify-self-end h-10 shrink-0 aspect-[4/3]"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="w-full pb-4 lg:pb-0 flex flex-col gap-4 lg:grid lg:grid-cols-[2fr_1fr] lg:gap-4"
|
||||
>
|
||||
<!-- Mobile: spacer for fixed top bar -->
|
||||
<div
|
||||
class="lg:hidden h-[calc(env(safe-area-inset-top)+56px)] lg:col-span-2 -mb-4"
|
||||
></div>
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0 w-full">
|
||||
<username-input
|
||||
class="relative w-full h-full block text-ellipsis overflow-hidden whitespace-nowrap"
|
||||
class="flex-1 min-w-0 h-10 lg:h-[50px]"
|
||||
></username-input>
|
||||
</div>
|
||||
|
||||
<!-- Pattern button (Mobile - inside bar, Desktop - hidden here) -->
|
||||
<pattern-input
|
||||
id="pattern-input-mobile"
|
||||
show-select-label
|
||||
class="aspect-square h-[50px] sm:h-[50px] lg:hidden shrink-0"
|
||||
></pattern-input>
|
||||
</div>
|
||||
|
||||
<!-- Pattern & Flag buttons (Desktop only - separate column) -->
|
||||
<div class="hidden lg:flex lg:col-span-3">
|
||||
<div class="w-full h-[60px] flex gap-2">
|
||||
<pattern-input
|
||||
id="pattern-input-desktop"
|
||||
id="pattern-input-mobile"
|
||||
show-select-label
|
||||
class="flex-1 h-full"
|
||||
adaptive-size
|
||||
class="shrink-0 lg:hidden"
|
||||
></pattern-input>
|
||||
<flag-input
|
||||
id="flag-input-desktop"
|
||||
show-select-label
|
||||
class="flex-1 h-full"
|
||||
></flag-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Primary Game Actions Area -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6 w-full">
|
||||
<!-- Left Column: Featured Lobbies / Quick Play -->
|
||||
<div class="lg:col-span-9 flex flex-col gap-6 min-w-0">
|
||||
<!-- Public Lobby Card -->
|
||||
<public-lobby
|
||||
class="block w-full transition-all duration-[50ms]"
|
||||
></public-lobby>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Custom Games & Modes -->
|
||||
<div class="lg:col-span-3">
|
||||
<div
|
||||
class="group relative isolate flex flex-col w-full h-40 lg:h-96 overflow-hidden rounded-2xl transition-all duration-300"
|
||||
>
|
||||
<div
|
||||
class="h-full flex flex-col bg-slate-900/40 backdrop-blur-sm rounded-2xl overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="py-2 bg-blue-900/20 text-center text-sm font-bold text-gray-300 uppercase tracking-widest"
|
||||
data-i18n="host_modal.label"
|
||||
></div>
|
||||
<div class="flex-1 p-2 flex flex-row lg:flex-col gap-2">
|
||||
<o-button
|
||||
id="single-player"
|
||||
data-i18n-title="main.solo"
|
||||
translationKey="main.solo"
|
||||
fill
|
||||
class="flex-1 transition-transform"
|
||||
></o-button>
|
||||
|
||||
<o-button
|
||||
id="host-lobby-button"
|
||||
data-i18n-title="main.create"
|
||||
translationKey="main.create"
|
||||
fill
|
||||
secondary
|
||||
class="flex-1 opacity-90 hover:opacity-100"
|
||||
></o-button>
|
||||
|
||||
<o-button
|
||||
id="join-private-lobby-button"
|
||||
data-i18n-title="main.join"
|
||||
translationKey="main.join"
|
||||
fill
|
||||
secondary
|
||||
class="flex-1 opacity-90 hover:opacity-100"
|
||||
></o-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Matchmaking Buttons (Full Width across entire grid) -->
|
||||
<div class="lg:col-span-12 flex flex-col gap-6">
|
||||
<matchmaking-button></matchmaking-button>
|
||||
<!-- Skin + flag: right col -->
|
||||
<div class="hidden lg:flex h-[60px] gap-2">
|
||||
<pattern-input
|
||||
id="pattern-input-desktop"
|
||||
show-select-label
|
||||
class="flex-1 h-full"
|
||||
></pattern-input>
|
||||
<flag-input
|
||||
id="flag-input-desktop"
|
||||
show-select-label
|
||||
class="flex-1 h-full"
|
||||
></flag-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<game-mode-selector></game-mode-selector>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { UserMeResponse } from "../../core/ApiSchemas";
|
||||
import { getUserMe, hasLinkedAccount } from "../Api";
|
||||
import { userAuth } from "../Auth";
|
||||
import { translateText } from "../Utils";
|
||||
import { BaseModal } from "./BaseModal";
|
||||
import { modalHeader } from "./ui/ModalHeader";
|
||||
|
||||
@customElement("ranked-modal")
|
||||
export class RankedModal extends BaseModal {
|
||||
@state() private elo: number | string = "...";
|
||||
@state() private userMeResponse: UserMeResponse | false = false;
|
||||
@state() private errorMessage: string | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.id = "page-ranked";
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
document.addEventListener(
|
||||
"userMeResponse",
|
||||
this.handleUserMeResponse as EventListener,
|
||||
);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
document.removeEventListener(
|
||||
"userMeResponse",
|
||||
this.handleUserMeResponse as EventListener,
|
||||
);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private handleUserMeResponse = (
|
||||
event: CustomEvent<UserMeResponse | false>,
|
||||
) => {
|
||||
this.errorMessage = null;
|
||||
this.userMeResponse = event.detail;
|
||||
this.updateElo();
|
||||
};
|
||||
|
||||
private updateElo() {
|
||||
if (this.errorMessage) {
|
||||
this.elo = translateText("map_component.error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasLinkedAccount(this.userMeResponse)) {
|
||||
this.elo =
|
||||
this.userMeResponse &&
|
||||
this.userMeResponse.player.leaderboard?.oneVone?.elo
|
||||
? this.userMeResponse.player.leaderboard.oneVone.elo
|
||||
: translateText("matchmaking_modal.no_elo");
|
||||
}
|
||||
}
|
||||
|
||||
protected override async onOpen(): Promise<void> {
|
||||
this.elo = "...";
|
||||
this.errorMessage = null;
|
||||
|
||||
try {
|
||||
const userMe = await getUserMe();
|
||||
this.userMeResponse = userMe;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user profile for ranked modal", error);
|
||||
this.userMeResponse = false;
|
||||
this.errorMessage = translateText("map_component.error");
|
||||
this.elo = translateText("map_component.error");
|
||||
} finally {
|
||||
this.updateElo();
|
||||
}
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
render() {
|
||||
const content = html`
|
||||
<div class="${this.modalContainerClass}">
|
||||
${modalHeader({
|
||||
title: translateText("mode_selector.ranked_title"),
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
})}
|
||||
<div class="flex-1 min-h-0 overflow-y-auto custom-scrollbar p-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
${this.renderCard(
|
||||
translateText("mode_selector.ranked_1v1_title"),
|
||||
this.errorMessage ??
|
||||
(hasLinkedAccount(this.userMeResponse)
|
||||
? translateText("matchmaking_modal.elo", { elo: this.elo })
|
||||
: translateText("mode_selector.ranked_title")),
|
||||
() => this.handleRanked(),
|
||||
)}
|
||||
${this.renderDisabledCard(
|
||||
translateText("mode_selector.ranked_2v2_title"),
|
||||
translateText("mode_selector.coming_soon"),
|
||||
)}
|
||||
${this.renderDisabledCard(
|
||||
translateText("mode_selector.coming_soon"),
|
||||
"",
|
||||
)}
|
||||
${this.renderDisabledCard(
|
||||
translateText("mode_selector.coming_soon"),
|
||||
"",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (this.inline) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return html`
|
||||
<o-modal ?hideHeader=${true} ?hideCloseButton=${true}>
|
||||
${content}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCard(title: string, subtitle: string, onClick: () => void) {
|
||||
return html`
|
||||
<button
|
||||
@click=${onClick}
|
||||
class="flex flex-col w-full h-28 sm:h-32 rounded-2xl bg-[color-mix(in_oklab,var(--frenchBlue)_70%,black)] border-0 transition-transform hover:scale-[1.02] active:scale-[0.98] p-6 items-center justify-center gap-3"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-1 text-center">
|
||||
<h3
|
||||
class="text-lg sm:text-xl font-bold text-white uppercase tracking-widest leading-tight"
|
||||
>
|
||||
${title}
|
||||
</h3>
|
||||
<p
|
||||
class="text-xs text-white/60 uppercase tracking-wider whitespace-pre-line leading-tight"
|
||||
>
|
||||
${subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDisabledCard(title: string, subtitle: string) {
|
||||
return html`
|
||||
<div
|
||||
class="group relative isolate flex flex-col w-full h-28 sm:h-32 overflow-hidden rounded-2xl bg-slate-900/40 backdrop-blur-md border-0 shadow-none p-6 items-center justify-center gap-3 opacity-50 cursor-not-allowed"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-1 text-center">
|
||||
<h3
|
||||
class="text-lg sm:text-xl font-bold text-white/60 uppercase tracking-widest leading-tight"
|
||||
>
|
||||
${title}
|
||||
</h3>
|
||||
<p
|
||||
class="text-xs text-white/40 uppercase tracking-wider whitespace-pre-line leading-tight"
|
||||
>
|
||||
${subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private async handleRanked() {
|
||||
if ((await userAuth()) === false) {
|
||||
this.close();
|
||||
window.showPage?.("page-account");
|
||||
return;
|
||||
}
|
||||
|
||||
document.dispatchEvent(new CustomEvent("open-matchmaking"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { LitElement, PropertyValues, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { translateText } from "../Utils";
|
||||
|
||||
const ACTIVE_CARD =
|
||||
"bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]";
|
||||
const INACTIVE_CARD =
|
||||
"bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20";
|
||||
const INPUT_CLASS =
|
||||
"w-full text-center rounded bg-black/60 text-white text-sm font-bold border border-white/20 focus:outline-none focus:border-blue-500 p-1 my-1";
|
||||
const CARD_LABEL_CLASS =
|
||||
"text-xs uppercase font-bold tracking-wider leading-tight break-words hyphens-auto";
|
||||
|
||||
function cardClass(active: boolean, extra = ""): string {
|
||||
return `w-full h-full rounded-xl border cursor-pointer transition-all duration-200 active:scale-95 ${extra} ${active ? ACTIVE_CARD : INACTIVE_CARD}`;
|
||||
}
|
||||
|
||||
@customElement("toggle-input-card")
|
||||
export class ToggleInputCard extends LitElement {
|
||||
@property({ attribute: false }) labelKey = "";
|
||||
@property({ type: Boolean, attribute: false }) checked = false;
|
||||
@property({ attribute: false }) inputId?: string;
|
||||
@property({ attribute: false }) inputType = "number";
|
||||
@property({ attribute: false }) inputMin?: number | string;
|
||||
@property({ attribute: false }) inputMax?: number | string;
|
||||
@property({ attribute: false }) inputStep?: number | string;
|
||||
@property({ attribute: false }) inputValue?: number | string;
|
||||
@property({ attribute: false }) inputAriaLabel?: string;
|
||||
@property({ attribute: false }) inputPlaceholder?: string;
|
||||
@property({ attribute: false }) defaultInputValue?: number | string;
|
||||
@property({ attribute: false }) minValidOnEnable?: number;
|
||||
@property({ attribute: false }) onToggle?: (
|
||||
checked: boolean,
|
||||
value: number | string | undefined,
|
||||
) => void;
|
||||
@property({ attribute: false }) onInput?: (e: Event) => void;
|
||||
@property({ attribute: false }) onChange?: (e: Event) => void;
|
||||
@property({ attribute: false }) onKeyDown?: (e: KeyboardEvent) => void;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues<this>) {
|
||||
if (!changedProperties.has("checked")) return;
|
||||
const previousChecked = changedProperties.get("checked");
|
||||
if (previousChecked === false && this.checked) {
|
||||
const input = this.querySelector("input");
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private toOptionalNumber(
|
||||
value: number | string | undefined,
|
||||
): number | undefined {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const numeric = Number(trimmed);
|
||||
return Number.isFinite(numeric) ? numeric : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private resolveValueOnEnable(): number | string | undefined {
|
||||
const currentValue = this.inputValue;
|
||||
|
||||
if (
|
||||
currentValue === undefined ||
|
||||
currentValue === null ||
|
||||
currentValue === ""
|
||||
) {
|
||||
return this.defaultInputValue;
|
||||
}
|
||||
|
||||
if (this.minValidOnEnable === undefined) {
|
||||
return currentValue;
|
||||
}
|
||||
|
||||
const numericValue = this.toOptionalNumber(currentValue);
|
||||
if (numericValue === undefined || numericValue < this.minValidOnEnable) {
|
||||
return this.defaultInputValue;
|
||||
}
|
||||
|
||||
return numericValue;
|
||||
}
|
||||
|
||||
private emitToggle() {
|
||||
const nextChecked = !this.checked;
|
||||
const nextValue = nextChecked ? this.resolveValueOnEnable() : undefined;
|
||||
this.onToggle?.(nextChecked, nextValue);
|
||||
}
|
||||
|
||||
private handleCardClick = () => {
|
||||
this.emitToggle();
|
||||
};
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="${cardClass(this.checked, "relative overflow-hidden")}">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed=${this.checked}
|
||||
@click=${this.handleCardClick}
|
||||
class="w-full h-full p-3 flex flex-col items-center justify-between gap-2 focus:outline-none"
|
||||
>
|
||||
<div
|
||||
class="w-5 h-5 rounded border flex items-center justify-center transition-colors mt-1 ${this
|
||||
.checked
|
||||
? "bg-blue-500 border-blue-500"
|
||||
: "border-white/20 bg-white/5"}"
|
||||
>
|
||||
${this.checked
|
||||
? html`<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-3 w-3 text-white"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>`
|
||||
: ""}
|
||||
</div>
|
||||
|
||||
${this.checked
|
||||
? html`<div class="h-[30px] my-1"></div>`
|
||||
: html`<div class="h-[2px] w-4 rounded my-3 bg-white/10"></div>`}
|
||||
|
||||
<span
|
||||
class="${CARD_LABEL_CLASS} text-center ${this.checked
|
||||
? "text-white"
|
||||
: "text-white/60"}"
|
||||
>
|
||||
${translateText(this.labelKey)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
${this.checked
|
||||
? html`
|
||||
<div
|
||||
class="absolute left-3 right-3 top-1/2 -translate-y-1/2 z-10"
|
||||
>
|
||||
<input
|
||||
type=${this.inputType}
|
||||
id=${this.inputId ?? nothing}
|
||||
min=${this.inputMin ?? nothing}
|
||||
max=${this.inputMax ?? nothing}
|
||||
step=${this.inputStep ?? nothing}
|
||||
.value=${String(this.inputValue ?? "")}
|
||||
class=${INPUT_CLASS}
|
||||
aria-label=${this.inputAriaLabel ?? nothing}
|
||||
placeholder=${this.inputPlaceholder ?? nothing}
|
||||
@input=${this.onInput}
|
||||
@change=${this.onChange}
|
||||
@keydown=${this.onKeyDown}
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export class OButton extends LitElement {
|
||||
@property({ type: Boolean }) blockDesktop = false;
|
||||
@property({ type: Boolean }) disable = false;
|
||||
@property({ type: Boolean }) fill = false;
|
||||
@property({ type: Boolean }) submit = false;
|
||||
private static readonly BASE_CLASS =
|
||||
"bg-blue-600 hover:bg-blue-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";
|
||||
|
||||
@@ -38,6 +39,7 @@ export class OButton extends LitElement {
|
||||
<button
|
||||
class=${classMap(this.getButtonClasses())}
|
||||
?disabled=${this.disable}
|
||||
type=${this.submit ? "submit" : "button"}
|
||||
>
|
||||
<span class="block min-w-0">
|
||||
${this.translationKey === ""
|
||||
|
||||
@@ -67,7 +67,7 @@ export class OModal extends LitElement {
|
||||
|
||||
const wrapperClass = this.inline
|
||||
? "relative flex flex-col w-full h-full m-0 max-w-full max-h-none shadow-none"
|
||||
: `relative flex flex-col w-[90%] min-w-[400px] max-w-[900px] m-8 rounded-lg shadow-[0_20px_60px_rgba(0,0,0,0.8)] max-h-[calc(100vh-4rem)] ${
|
||||
: `relative flex flex-col w-full h-full lg:w-[90%] lg:h-auto lg:min-w-[400px] lg:max-w-[900px] lg:m-8 lg:rounded-lg shadow-[0_20px_60px_rgba(0,0,0,0.8)] lg:max-h-[calc(100vh-4rem)] ${
|
||||
this.alwaysMaximized ? "h-auto" : ""
|
||||
}`;
|
||||
const wrapperStyle =
|
||||
@@ -101,7 +101,7 @@ export class OModal extends LitElement {
|
||||
</div>`
|
||||
: html``}
|
||||
<section
|
||||
class="relative flex-1 min-h-0 p-[1.4rem] text-white bg-[#23232382] backdrop-blur-md rounded-lg overflow-y-auto"
|
||||
class="relative flex-1 min-h-0 p-0 lg:p-[1.4rem] text-white bg-[#23232382] backdrop-blur-md lg:rounded-lg overflow-y-auto"
|
||||
>
|
||||
<slot></slot>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
type SelectOption = {
|
||||
value: number | string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
@customElement("setting-select")
|
||||
export class SettingSelect extends LitElement {
|
||||
@property() label = "Setting";
|
||||
@property() description = "";
|
||||
@property({ type: Array }) options: SelectOption[] = [];
|
||||
@property({ type: String }) value = "";
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private handleChange(e: Event) {
|
||||
const input = e.target as HTMLSelectElement;
|
||||
const selected = this.options.find(
|
||||
(option) => String(option.value) === input.value,
|
||||
);
|
||||
const selectedValue = selected?.value ?? input.value;
|
||||
this.value = String(selectedValue);
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("change", {
|
||||
detail: { value: selectedValue },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
class="flex flex-col w-full p-4 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 transition-all gap-3"
|
||||
>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<label
|
||||
class="text-white font-bold text-base block mb-1"
|
||||
for="setting-select-input"
|
||||
>${this.label}</label
|
||||
>
|
||||
<div class="text-white/50 text-sm leading-snug">
|
||||
${this.description}
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative w-full">
|
||||
<select
|
||||
id="setting-select-input"
|
||||
class="w-full appearance-none py-2 pl-3 pr-9 border border-white/20 rounded-lg bg-black/40 text-white font-mono text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all"
|
||||
.value=${String(this.value)}
|
||||
@change=${this.handleChange}
|
||||
>
|
||||
${this.options.map(
|
||||
(option) =>
|
||||
html`<option
|
||||
value=${String(option.value)}
|
||||
?selected=${String(option.value) === String(this.value)}
|
||||
>
|
||||
${option.label}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
<span
|
||||
class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-white/60"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 10.94l3.71-3.71a.75.75 0 1 1 1.06 1.06l-4.24 4.24a.75.75 0 0 1-1.06 0L5.21 8.29a.75.75 0 0 1 .02-1.08z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -48,9 +48,9 @@ export class SettingSlider extends LitElement {
|
||||
|
||||
return html`
|
||||
<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 ${rainbowClass}"
|
||||
class="flex flex-col sm:flex-row sm:items-center sm:justify-between w-full p-4 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 transition-all gap-3 sm:gap-4 ${rainbowClass}"
|
||||
>
|
||||
<div class="flex flex-col flex-1 min-w-0 mr-4">
|
||||
<div class="flex flex-col flex-1 min-w-0 sm:mr-4">
|
||||
<label class="text-white font-bold text-base block mb-1"
|
||||
>${this.label}</label
|
||||
>
|
||||
@@ -59,21 +59,28 @@ export class SettingSlider extends LitElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-end gap-2 shrink-0 w-[200px]">
|
||||
<span class="text-white font-bold text-sm">${this.value}%</span>
|
||||
<input
|
||||
type="range"
|
||||
class="w-full appearance-none h-2 bg-transparent rounded outline-none
|
||||
<div
|
||||
class="flex flex-col items-start sm:items-end gap-2 shrink-0 w-full sm:w-[200px]"
|
||||
>
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
<input
|
||||
type="range"
|
||||
class="flex-1 w-auto appearance-none h-2 bg-transparent rounded outline-none
|
||||
[&::-webkit-slider-runnable-track]:h-2 [&::-webkit-slider-runnable-track]:rounded [&::-webkit-slider-runnable-track]:bg-[image:linear-gradient(to_right,#3b82f6_0%,#3b82f6_var(--fill),rgba(255,255,255,0.1)_var(--fill),rgba(255,255,255,0.1)_100%)]
|
||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-[18px] [&::-webkit-slider-thumb]:w-[18px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-blue-500 [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:-mt-[6px] [&::-webkit-slider-thumb]:shadow-[0_0_0_4px_rgba(59,130,246,0.2)] [&::-webkit-slider-thumb]:transition-all active:[&::-webkit-slider-thumb]:scale-110 active:[&::-webkit-slider-thumb]:shadow-[0_0_0_6px_rgba(59,130,246,0.3)]
|
||||
[&::-moz-range-track]:h-2 [&::-moz-range-track]:rounded [&::-moz-range-track]:bg-white/10
|
||||
[&::-moz-range-progress]:h-2 [&::-moz-range-progress]:rounded [&::-moz-range-progress]:bg-blue-500
|
||||
[&::-moz-range-thumb]:h-[18px] [&::-moz-range-thumb]:w-[18px] [&::-moz-range-thumb]:border-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-blue-500 [&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:shadow-[0_0_0_4px_rgba(59,130,246,0.2)] [&::-moz-range-thumb]:transition-all active:[&::-moz-range-thumb]:scale-110 active:[&::-moz-range-thumb]:shadow-[0_0_0_6px_rgba(59,130,246,0.3)]"
|
||||
min=${this.min}
|
||||
max=${this.max}
|
||||
.value=${String(this.value)}
|
||||
@input=${this.handleInput}
|
||||
/>
|
||||
min=${this.min}
|
||||
max=${this.max}
|
||||
.value=${String(this.value)}
|
||||
@input=${this.handleInput}
|
||||
/>
|
||||
<span
|
||||
class="text-white font-bold text-sm shrink-0 text-right min-w-[3ch]"
|
||||
>${this.value}%</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -72,8 +72,8 @@ export class GameList extends LitElement {
|
||||
>
|
||||
${translateText("game_list.mode")}:
|
||||
${game.mode === GameMode.FFA
|
||||
? translateText("game_list.mode_ffa")
|
||||
: html`${translateText("game_list.mode_team")}`}
|
||||
? translateText("game_mode.ffa")
|
||||
: html`${translateText("game_mode.teams")}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,8 +50,8 @@ export class PlayerStatsTreeView extends LitElement {
|
||||
|
||||
private labelForMode(m: GameMode) {
|
||||
return m === GameMode.FFA
|
||||
? translateText("player_stats_tree.mode_ffa")
|
||||
: translateText("player_stats_tree.mode_team");
|
||||
? translateText("game_mode.ffa")
|
||||
: translateText("game_mode.teams");
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
|
||||
@@ -195,189 +195,208 @@ export class LeaderboardClanTable extends LitElement {
|
||||
const maxGames = Math.max(...clans.map((c) => c.games), 1);
|
||||
|
||||
return html`
|
||||
<div class="h-full px-6 pb-6">
|
||||
<div
|
||||
class="h-full overflow-y-auto overflow-x-auto rounded-xl border border-white/5 bg-black/20"
|
||||
>
|
||||
<table class="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr
|
||||
class="text-white/40 text-[10px] uppercase tracking-wider border-b border-white/5 bg-white/2"
|
||||
>
|
||||
<th class="py-4 px-4 text-center font-bold w-16">
|
||||
${translateText("leaderboard_modal.rank")}
|
||||
</th>
|
||||
<th class="py-4 px-4 text-left font-bold">
|
||||
${translateText("leaderboard_modal.clan")}
|
||||
</th>
|
||||
<th
|
||||
class="py-4 px-4 text-right font-bold w-32 cursor-pointer hover:text-white/60 transition-colors"
|
||||
<div class="h-full">
|
||||
<div class="h-full border border-white/5 bg-black/20">
|
||||
<div
|
||||
class="h-full overflow-y-auto overflow-x-auto scrollbar-thin scrollbar-thumb-white/20"
|
||||
>
|
||||
<table class="w-full text-sm border-collapse table-fixed">
|
||||
<colgroup>
|
||||
<col style="width: 4rem" />
|
||||
<col style="width: 5rem" />
|
||||
<col style="width: 8rem" />
|
||||
<col style="width: 6rem" />
|
||||
<col style="width: 6rem" />
|
||||
<col style="width: 6rem" />
|
||||
</colgroup>
|
||||
<thead class="sticky top-0 z-10">
|
||||
<tr
|
||||
class="text-white/40 text-[10px] uppercase tracking-wider border-b border-white/5 bg-[#1e2433]"
|
||||
>
|
||||
<button
|
||||
@click=${() => this.handleSort("games")}
|
||||
aria-sort=${this.sortBy === "games"
|
||||
? this.sortOrder === "asc"
|
||||
? "ascending"
|
||||
: "descending"
|
||||
: "none"}
|
||||
<th class="py-4 px-4 text-center font-bold">
|
||||
${translateText("leaderboard_modal.rank")}
|
||||
</th>
|
||||
<th class="py-4 px-4 text-left font-bold">
|
||||
${translateText("leaderboard_modal.clan")}
|
||||
</th>
|
||||
<th
|
||||
class="py-4 px-4 text-right font-bold cursor-pointer hover:text-white/60 transition-colors"
|
||||
>
|
||||
${translateText("leaderboard_modal.games")}
|
||||
${this.sortBy === "games"
|
||||
? this.sortOrder === "asc"
|
||||
? "↑"
|
||||
: "↓"
|
||||
: "↕"}
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
class="py-4 px-4 text-right font-bold hidden md:table-cell cursor-pointer hover:text-white/60 transition-colors"
|
||||
title=${translateText("leaderboard_modal.win_score_tooltip")}
|
||||
>
|
||||
<button
|
||||
@click=${() => this.handleSort("winScore")}
|
||||
aria-sort=${this.sortBy === "winScore"
|
||||
? this.sortOrder === "asc"
|
||||
? "ascending"
|
||||
: "descending"
|
||||
: "none"}
|
||||
<button
|
||||
class="whitespace-nowrap uppercase"
|
||||
@click=${() => this.handleSort("games")}
|
||||
aria-sort=${this.sortBy === "games"
|
||||
? this.sortOrder === "asc"
|
||||
? "ascending"
|
||||
: "descending"
|
||||
: "none"}
|
||||
>
|
||||
${translateText("leaderboard_modal.games")}
|
||||
${this.sortBy === "games"
|
||||
? this.sortOrder === "asc"
|
||||
? "↑"
|
||||
: "↓"
|
||||
: "↕"}
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
class="py-4 px-4 text-right font-bold cursor-pointer hover:text-white/60 transition-colors"
|
||||
title=${translateText(
|
||||
"leaderboard_modal.win_score_tooltip",
|
||||
)}
|
||||
>
|
||||
${translateText("leaderboard_modal.win_score")}
|
||||
${this.sortBy === "winScore"
|
||||
? this.sortOrder === "asc"
|
||||
? "↑"
|
||||
: "↓"
|
||||
: "↕"}
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
class="py-4 px-4 text-right font-bold hidden md:table-cell cursor-pointer hover:text-white/60 transition-colors"
|
||||
title=${translateText("leaderboard_modal.loss_score_tooltip")}
|
||||
>
|
||||
<button
|
||||
@click=${() => this.handleSort("lossScore")}
|
||||
aria-sort=${this.sortBy === "lossScore"
|
||||
? this.sortOrder === "asc"
|
||||
? "ascending"
|
||||
: "descending"
|
||||
: "none"}
|
||||
<button
|
||||
class="whitespace-nowrap uppercase"
|
||||
@click=${() => this.handleSort("winScore")}
|
||||
aria-sort=${this.sortBy === "winScore"
|
||||
? this.sortOrder === "asc"
|
||||
? "ascending"
|
||||
: "descending"
|
||||
: "none"}
|
||||
>
|
||||
${translateText("leaderboard_modal.win_score")}
|
||||
${this.sortBy === "winScore"
|
||||
? this.sortOrder === "asc"
|
||||
? "↑"
|
||||
: "↓"
|
||||
: "↕"}
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
class="py-4 px-4 text-right font-bold cursor-pointer hover:text-white/60 transition-colors"
|
||||
title=${translateText(
|
||||
"leaderboard_modal.loss_score_tooltip",
|
||||
)}
|
||||
>
|
||||
${translateText("leaderboard_modal.loss_score")}
|
||||
${this.sortBy === "lossScore"
|
||||
? this.sortOrder === "asc"
|
||||
? "↑"
|
||||
: "↓"
|
||||
: "↕"}
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
class="py-4 px-4 text-right font-bold pr-6 cursor-pointer hover:text-white/60 transition-colors"
|
||||
>
|
||||
<button
|
||||
@click=${() => this.handleSort("ratio")}
|
||||
aria-sort=${this.sortBy === "ratio"
|
||||
? this.sortOrder === "asc"
|
||||
? "ascending"
|
||||
: "descending"
|
||||
: "none"}
|
||||
<button
|
||||
class="whitespace-nowrap uppercase"
|
||||
@click=${() => this.handleSort("lossScore")}
|
||||
aria-sort=${this.sortBy === "lossScore"
|
||||
? this.sortOrder === "asc"
|
||||
? "ascending"
|
||||
: "descending"
|
||||
: "none"}
|
||||
>
|
||||
${translateText("leaderboard_modal.loss_score")}
|
||||
${this.sortBy === "lossScore"
|
||||
? this.sortOrder === "asc"
|
||||
? "↑"
|
||||
: "↓"
|
||||
: "↕"}
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
class="py-4 px-4 text-right font-bold pr-6 cursor-pointer hover:text-white/60 transition-colors"
|
||||
>
|
||||
${translateText("leaderboard_modal.win_loss_ratio")}
|
||||
${this.sortBy === "ratio"
|
||||
? this.sortOrder === "asc"
|
||||
? "↑"
|
||||
: "↓"
|
||||
: "↕"}
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${sorted.map((clan, index) => {
|
||||
const displayRank = index + 1;
|
||||
const rankColor =
|
||||
displayRank === 1
|
||||
? "text-yellow-400 bg-yellow-400/10 ring-1 ring-yellow-400/20"
|
||||
: displayRank === 2
|
||||
? "text-slate-300 bg-slate-400/10 ring-1 ring-slate-400/20"
|
||||
: displayRank === 3
|
||||
? "text-amber-600 bg-amber-600/10 ring-1 ring-amber-600/20"
|
||||
: "text-white/40 bg-white/5";
|
||||
const rankIcon =
|
||||
displayRank === 1
|
||||
? "👑"
|
||||
: displayRank === 2
|
||||
? "🥈"
|
||||
: displayRank === 3
|
||||
? "🥉"
|
||||
: String(displayRank);
|
||||
<button
|
||||
class="whitespace-nowrap uppercase"
|
||||
@click=${() => this.handleSort("ratio")}
|
||||
aria-sort=${this.sortBy === "ratio"
|
||||
? this.sortOrder === "asc"
|
||||
? "ascending"
|
||||
: "descending"
|
||||
: "none"}
|
||||
>
|
||||
${translateText("leaderboard_modal.win_loss_ratio")}
|
||||
${this.sortBy === "ratio"
|
||||
? this.sortOrder === "asc"
|
||||
? "↑"
|
||||
: "↓"
|
||||
: "↕"}
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${sorted.map((clan, index) => {
|
||||
const displayRank = index + 1;
|
||||
const rankColor =
|
||||
displayRank === 1
|
||||
? "text-yellow-400 bg-yellow-400/10 ring-1 ring-yellow-400/20"
|
||||
: displayRank === 2
|
||||
? "text-slate-300 bg-slate-400/10 ring-1 ring-slate-400/20"
|
||||
: displayRank === 3
|
||||
? "text-amber-600 bg-amber-600/10 ring-1 ring-amber-600/20"
|
||||
: "text-white/40 bg-white/5";
|
||||
const rankIcon =
|
||||
displayRank === 1
|
||||
? "👑"
|
||||
: displayRank === 2
|
||||
? "🥈"
|
||||
: displayRank === 3
|
||||
? "🥉"
|
||||
: String(displayRank);
|
||||
|
||||
return html`
|
||||
<tr
|
||||
class="border-b border-white/5 hover:bg-white/[0.07] transition-colors group"
|
||||
>
|
||||
<td class="py-3 px-4 text-center">
|
||||
<div
|
||||
class="w-10 h-10 mx-auto flex items-center justify-center rounded-lg font-bold font-mono text-lg ${rankColor}"
|
||||
>
|
||||
${rankIcon}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 px-4 font-bold text-blue-300">
|
||||
<div
|
||||
class="px-2.5 py-1 rounded bg-blue-500/10 border border-blue-500/20 inline-block"
|
||||
>
|
||||
${clan.clanTag}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 px-4 text-right">
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
<span class="text-white font-mono font-medium"
|
||||
>${clan.games.toLocaleString()}</span
|
||||
>
|
||||
return html`
|
||||
<tr
|
||||
class="border-b border-white/5 hover:bg-white/[0.07] transition-colors group"
|
||||
>
|
||||
<td class="py-3 px-4 text-center">
|
||||
<div
|
||||
class="w-24 h-1 bg-white/10 rounded-full overflow-hidden"
|
||||
class="w-10 h-10 mx-auto flex items-center justify-center rounded-lg font-bold font-mono text-lg ${rankColor}"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-blue-500/50 rounded-full"
|
||||
style="width: ${(clan.games / maxGames) * 100}%"
|
||||
></div>
|
||||
${rankIcon}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="py-3 px-4 text-right font-mono text-green-400/90 hidden md:table-cell"
|
||||
>
|
||||
${clan.weightedWins.toLocaleString("fullwide", {
|
||||
maximumFractionDigits: 1,
|
||||
})}
|
||||
</td>
|
||||
<td
|
||||
class="py-3 px-4 text-right font-mono text-red-400/90 hidden md:table-cell"
|
||||
>
|
||||
${clan.weightedLosses.toLocaleString("fullwide", {
|
||||
maximumFractionDigits: 1,
|
||||
})}
|
||||
</td>
|
||||
<td class="py-3 px-4 text-right pr-6">
|
||||
<div class="inline-flex flex-col items-end">
|
||||
<span
|
||||
class="font-mono font-bold ${clan.weightedWLRatio >= 1
|
||||
? "text-green-400"
|
||||
: "text-red-400"}"
|
||||
>${clan.weightedWLRatio.toLocaleString("fullwide", {
|
||||
maximumFractionDigits: 2,
|
||||
})}</span
|
||||
</td>
|
||||
<td class="py-3 px-4 font-bold text-blue-300">
|
||||
<div
|
||||
class="px-2.5 py-1 rounded bg-blue-500/10 border border-blue-500/20 inline-block"
|
||||
>
|
||||
<span
|
||||
class="text-[10px] uppercase text-white/30 font-bold tracking-wider"
|
||||
>${translateText("leaderboard_modal.ratio")}</span
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
${clan.clanTag}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 px-4 text-right">
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
<span class="text-white font-mono font-medium"
|
||||
>${clan.games.toLocaleString()}</span
|
||||
>
|
||||
<div
|
||||
class="w-24 h-1 bg-white/10 rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-blue-500/50 rounded-full"
|
||||
style="width: ${(clan.games / maxGames) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="py-3 px-4 text-right font-mono text-green-400/90"
|
||||
>
|
||||
${clan.weightedWins.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 1,
|
||||
})}
|
||||
</td>
|
||||
<td
|
||||
class="py-3 px-4 text-right font-mono text-red-400/90"
|
||||
>
|
||||
${clan.weightedLosses.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 1,
|
||||
})}
|
||||
</td>
|
||||
<td class="py-3 px-4 text-right pr-6">
|
||||
<div class="inline-flex flex-col items-end">
|
||||
<span
|
||||
class="font-mono font-bold ${clan.weightedWLRatio >=
|
||||
1
|
||||
? "text-green-400"
|
||||
: "text-red-400"}"
|
||||
>${clan.weightedWLRatio.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
})}</span
|
||||
>
|
||||
<span
|
||||
class="text-[10px] uppercase text-white/30 font-bold tracking-wider"
|
||||
>${translateText("leaderboard_modal.ratio")}</span
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { virtualize } from "@lit-labs/virtualizer/virtualize.js";
|
||||
import { html, LitElement } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { PlayerLeaderboardEntry } from "../../../core/ApiSchemas";
|
||||
import { RankedType } from "../../../core/game/Game";
|
||||
import { fetchPlayerLeaderboard, getUserMe } from "../../Api";
|
||||
import { translateText } from "../../Utils";
|
||||
|
||||
@@ -22,7 +22,7 @@ export class LeaderboardPlayerList extends LitElement {
|
||||
private currentUserId: string | null = null;
|
||||
private currentUserIdLoaded = false;
|
||||
|
||||
@query(".virtualizer-container") private virtualizerContainer?: HTMLElement;
|
||||
@query(".scroll-container") private scrollContainer?: HTMLElement;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
@@ -68,19 +68,19 @@ export class LeaderboardPlayerList extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPlayers: PlayerLeaderboardEntry[] = result["1v1"].map(
|
||||
(entry) => ({
|
||||
rank: entry.rank,
|
||||
playerId: entry.public_id,
|
||||
username: entry.username,
|
||||
clanTag: entry.clanTag ?? undefined,
|
||||
elo: entry.elo,
|
||||
games: entry.total,
|
||||
wins: entry.wins,
|
||||
losses: entry.losses,
|
||||
winRate: entry.total > 0 ? entry.wins / entry.total : 0,
|
||||
}),
|
||||
);
|
||||
const nextPlayers: PlayerLeaderboardEntry[] = result[
|
||||
RankedType.OneVOne
|
||||
].map((entry) => ({
|
||||
rank: entry.rank,
|
||||
playerId: entry.public_id,
|
||||
username: entry.username,
|
||||
clanTag: entry.clanTag ?? undefined,
|
||||
elo: entry.elo,
|
||||
games: entry.total,
|
||||
wins: entry.wins,
|
||||
losses: entry.losses,
|
||||
winRate: entry.total > 0 ? entry.wins / entry.total : 0,
|
||||
}));
|
||||
|
||||
const receivedCount = nextPlayers.length;
|
||||
if (reset) {
|
||||
@@ -151,12 +151,12 @@ export class LeaderboardPlayerList extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.virtualizerContainer || !this.isVisible()) {
|
||||
if (!this.scrollContainer || !this.isVisible()) {
|
||||
this.showStickyUser = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRow = this.virtualizerContainer.querySelector(
|
||||
const currentRow = this.scrollContainer.querySelector(
|
||||
'[data-current-user="true"]',
|
||||
) as HTMLElement | null;
|
||||
|
||||
@@ -165,7 +165,7 @@ export class LeaderboardPlayerList extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerRect = this.virtualizerContainer.getBoundingClientRect();
|
||||
const containerRect = this.scrollContainer.getBoundingClientRect();
|
||||
const rowRect = currentRow.getBoundingClientRect();
|
||||
const isVisible =
|
||||
rowRect.top >= containerRect.top &&
|
||||
@@ -187,12 +187,12 @@ export class LeaderboardPlayerList extends LitElement {
|
||||
private maybeLoadMorePlayers() {
|
||||
if (this.isLoading || this.isLoadingMore) return;
|
||||
if (!this.playerHasMore || this.error || this.loadMoreError) return;
|
||||
if (!this.virtualizerContainer || !this.isVisible()) return;
|
||||
if (!this.scrollContainer || !this.isVisible()) return;
|
||||
|
||||
const threshold = 64 * 3;
|
||||
const scrollTop = this.virtualizerContainer.scrollTop;
|
||||
const containerHeight = this.virtualizerContainer.clientHeight;
|
||||
const scrollHeight = this.virtualizerContainer.scrollHeight;
|
||||
const scrollTop = this.scrollContainer.scrollTop;
|
||||
const containerHeight = this.scrollContainer.clientHeight;
|
||||
const scrollHeight = this.scrollContainer.scrollHeight;
|
||||
const nearBottom = scrollTop + containerHeight >= scrollHeight - threshold;
|
||||
|
||||
if (containerHeight === 0 || scrollHeight === 0) return; // guard
|
||||
@@ -210,7 +210,6 @@ export class LeaderboardPlayerList extends LitElement {
|
||||
private renderPlayerRow(player: PlayerLeaderboardEntry) {
|
||||
const isCurrentUser = this.currentUserEntry?.playerId === player.playerId;
|
||||
const displayRank = player.rank;
|
||||
const winRate = player.games > 0 ? player.wins / player.games : 0;
|
||||
|
||||
const rankColor =
|
||||
{
|
||||
@@ -227,60 +226,56 @@ export class LeaderboardPlayerList extends LitElement {
|
||||
}?.[displayRank] ?? String(displayRank);
|
||||
|
||||
return html`
|
||||
<div
|
||||
<tr
|
||||
data-current-user=${isCurrentUser ? "true" : "false"}
|
||||
class="flex items-center border-b border-white/5 py-3 px-6 hover:bg-white/[0.07] transition-colors w-full ${isCurrentUser
|
||||
? "bg-blue-500/15 border-l-4 border-l-blue-500 pl-5"
|
||||
class="border-b border-white/5 hover:bg-white/[0.07] transition-colors group ${isCurrentUser
|
||||
? "bg-blue-500/15"
|
||||
: ""}"
|
||||
>
|
||||
<div class="w-16 shrink-0 text-center">
|
||||
<td class="py-3 px-4 text-center">
|
||||
<div
|
||||
class="w-10 h-10 mx-auto flex items-center justify-center rounded-lg font-bold font-mono text-lg ${rankColor}"
|
||||
>
|
||||
${rankIcon}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 flex items-center gap-3 overflow-hidden ml-4">
|
||||
<span class="font-bold text-blue-300 truncate text-base"
|
||||
>${player.username}</span
|
||||
>
|
||||
${player.clanTag
|
||||
? html`<div
|
||||
class="px-2.5 py-1 rounded bg-blue-500/10 border border-blue-500/20 text-[10px] font-bold text-blue-300 shrink-0"
|
||||
>
|
||||
${player.clanTag}
|
||||
</div>`
|
||||
: ""}
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-1 w-32">
|
||||
<div class="text-right font-mono text-white font-medium">
|
||||
${player.elo}
|
||||
<span class="text-[10px] text-white/30 truncate"
|
||||
>${translateText("leaderboard_modal.elo")}</span
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<div class="flex items-center gap-2">
|
||||
${player.clanTag
|
||||
? html`<div
|
||||
class="px-2 py-0.5 rounded bg-blue-500/10 border border-blue-500/20 text-[10px] font-bold text-blue-300 shrink-0"
|
||||
>
|
||||
${player.clanTag}
|
||||
</div>`
|
||||
: ""}
|
||||
<span class="font-bold text-blue-300 truncate text-base"
|
||||
>${player.clanTag
|
||||
? player.username.replace(/^\[.*?\]\s*/, "")
|
||||
: player.username}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-col items-end gap-1 w-32 hidden md:flex">
|
||||
<div class="text-right font-mono text-white font-medium">
|
||||
${player.games}
|
||||
<span class="text-[10px] text-white/30 uppercase"
|
||||
>${translateText("leaderboard_modal.games")}</span
|
||||
</td>
|
||||
<td class="py-3 px-4 text-right">
|
||||
<span class="font-mono text-white font-medium">${player.elo}</span>
|
||||
</td>
|
||||
<td class="py-3 px-4 text-right">
|
||||
<span class="font-mono text-white font-medium">${player.games}</span>
|
||||
</td>
|
||||
<td class="py-3 px-4 text-right pr-6">
|
||||
<div class="inline-flex flex-col items-end">
|
||||
<span
|
||||
class="font-mono font-bold ${player.winRate >= 0.5
|
||||
? "text-green-400"
|
||||
: "text-red-400"}"
|
||||
>${(player.winRate * 100).toFixed(1)}%</span
|
||||
>
|
||||
<span
|
||||
class="text-[10px] uppercase text-white/30 font-bold tracking-wider"
|
||||
>${translateText("leaderboard_modal.ratio")}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inline-flex flex-col items-end pr-6 w-32">
|
||||
<span
|
||||
class="font-mono font-bold ${winRate >= 0.5
|
||||
? "text-green-400"
|
||||
: "text-red-400"}"
|
||||
>${(winRate * 100).toFixed(1)}%</span
|
||||
>
|
||||
<span
|
||||
class="text-[10px] uppercase text-white/30 font-bold tracking-wider"
|
||||
>${translateText("leaderboard_modal.ratio")}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -371,52 +366,61 @@ export class LeaderboardPlayerList extends LitElement {
|
||||
if (this.error) return this.renderError();
|
||||
|
||||
return html`
|
||||
<div class="flex flex-col h-full overflow-hidden">
|
||||
<div
|
||||
class="flex items-center text-[10px] uppercase tracking-wider text-white/40 font-bold px-6 py-4 border-b border-white/5 bg-white/2"
|
||||
>
|
||||
<div class="w-16 text-center">
|
||||
${translateText("leaderboard_modal.rank")}
|
||||
</div>
|
||||
<div class="flex-1 ml-4">
|
||||
${translateText("leaderboard_modal.player")}
|
||||
</div>
|
||||
<div class="w-32 text-right">
|
||||
${translateText("leaderboard_modal.elo")}
|
||||
</div>
|
||||
<div class="w-32 text-right hidden md:block">
|
||||
${translateText("leaderboard_modal.games")}
|
||||
</div>
|
||||
<div class="w-32 text-right pr-6">
|
||||
${translateText("leaderboard_modal.win_loss_ratio")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex-1 min-h-0">
|
||||
<div class="h-full">
|
||||
<div class="h-full border border-white/5 bg-black/20 relative">
|
||||
<div
|
||||
class="virtualizer-container h-full overflow-y-auto scrollbar-thin scrollbar-thumb-white/20 ${this
|
||||
class="scroll-container h-full overflow-y-auto overflow-x-auto scrollbar-thin scrollbar-thumb-white/20 ${this
|
||||
.showStickyUser
|
||||
? "pb-20"
|
||||
: "pb-0"}"
|
||||
@scroll=${() => this.handleScroll()}
|
||||
>
|
||||
${virtualize({
|
||||
items: this.playerData,
|
||||
renderItem: (player) => this.renderPlayerRow(player),
|
||||
scroller: true,
|
||||
})}
|
||||
<table class="w-full text-sm border-collapse table-fixed">
|
||||
<colgroup>
|
||||
<col style="width: 4rem" />
|
||||
<col style="width: 12rem" />
|
||||
<col style="width: 6rem" />
|
||||
<col style="width: 6rem" />
|
||||
<col style="width: 6rem" />
|
||||
</colgroup>
|
||||
<thead class="sticky top-0 z-10">
|
||||
<tr
|
||||
class="text-white/40 text-[10px] uppercase tracking-wider border-b border-white/5 bg-[#1e2433]"
|
||||
>
|
||||
<th class="py-4 px-4 text-center font-bold">
|
||||
${translateText("leaderboard_modal.rank")}
|
||||
</th>
|
||||
<th class="py-4 px-4 text-left font-bold">
|
||||
${translateText("leaderboard_modal.player")}
|
||||
</th>
|
||||
<th class="py-4 px-4 text-right font-bold">
|
||||
${translateText("leaderboard_modal.elo")}
|
||||
</th>
|
||||
<th class="py-4 px-4 text-right font-bold">
|
||||
${translateText("leaderboard_modal.games")}
|
||||
</th>
|
||||
<th class="py-4 px-4 text-right font-bold pr-6">
|
||||
${translateText("leaderboard_modal.win_loss_ratio")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${this.playerData.map((player) => this.renderPlayerRow(player))}
|
||||
</tbody>
|
||||
</table>
|
||||
${this.renderPlayerFooter()}
|
||||
</div>
|
||||
${this.currentUserEntry
|
||||
? html`
|
||||
<div class="absolute inset-x-0 bottom-0">
|
||||
<div class="absolute inset-x-0 bottom-0 z-20">
|
||||
<div
|
||||
class="bg-blue-600/90 backdrop-blur-md border-t border-blue-400/30 py-4 px-6 shadow-2xl flex items-center transition-all duration-200 ${this
|
||||
.showStickyUser
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 translate-y-3 pointer-events-none"}"
|
||||
aria-hidden=${this.showStickyUser ? "false" : "true"}
|
||||
aria-hidden=${this.showStickyUser ? nothing : "true"}
|
||||
>
|
||||
<div class="w-16 text-center">
|
||||
<div class="w-10 text-center">
|
||||
<div
|
||||
class="w-10 h-10 mx-auto flex items-center justify-center rounded-lg font-bold font-mono text-lg bg-white/20 text-white"
|
||||
>
|
||||
@@ -431,10 +435,15 @@ export class LeaderboardPlayerList extends LitElement {
|
||||
)}</span
|
||||
>
|
||||
<span class="font-bold text-white text-base"
|
||||
>${this.currentUserEntry.username}</span
|
||||
>${this.currentUserEntry.clanTag
|
||||
? this.currentUserEntry.username.replace(
|
||||
/^\[.*?\]\s*/,
|
||||
"",
|
||||
)
|
||||
: this.currentUserEntry.username}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-col items-end w-32">
|
||||
<div class="flex flex-col items-end w-20">
|
||||
<div class="font-mono text-white font-bold text-lg">
|
||||
${this.currentUserEntry.elo}
|
||||
<span class="text-[10px] text-white/60"
|
||||
|
||||
@@ -47,7 +47,7 @@ export class LeaderboardTabs extends LitElement {
|
||||
return html`
|
||||
<div
|
||||
role="tablist"
|
||||
class="flex gap-2 p-1 bg-white/5 rounded-full border border-white/10 mb-6 w-fit mx-auto mt-4"
|
||||
class="flex gap-2 p-1 bg-white/5 rounded-full border border-white/10 mb-4 w-fit mx-auto mt-4"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -15,6 +15,8 @@ export class MapDisplay extends LitElement {
|
||||
@state() private mapName: string | null = null;
|
||||
@state() private isLoading = true;
|
||||
@state() private hasNations = true;
|
||||
private observer: IntersectionObserver | null = null;
|
||||
private dataLoaded = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
@@ -22,7 +24,23 @@ export class MapDisplay extends LitElement {
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.loadMapData();
|
||||
this.observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting) && !this.dataLoaded) {
|
||||
this.dataLoaded = true;
|
||||
this.loadMapData();
|
||||
this.observer?.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "200px" },
|
||||
);
|
||||
this.observer.observe(this);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.observer?.disconnect();
|
||||
this.observer = null;
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private async loadMapData() {
|
||||
@@ -32,7 +50,7 @@ export class MapDisplay extends LitElement {
|
||||
this.isLoading = true;
|
||||
const mapValue = GameMapType[this.mapKey as keyof typeof GameMapType];
|
||||
const data = terrainMapFileLoader.getMapData(mapValue);
|
||||
this.mapWebpPath = await data.webpPath();
|
||||
this.mapWebpPath = data.webpPath;
|
||||
const manifest = await data.manifest();
|
||||
this.mapName = manifest.name;
|
||||
this.hasNations =
|
||||
@@ -53,6 +71,10 @@ export class MapDisplay extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private preventImageDrag(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
@@ -61,10 +83,10 @@ export class MapDisplay extends LitElement {
|
||||
aria-selected="${this.selected}"
|
||||
aria-label="${this.translation ?? this.mapName ?? this.mapKey}"
|
||||
@keydown="${this.handleKeydown}"
|
||||
class="w-full h-full p-3 flex flex-col items-center justify-between rounded-xl border cursor-pointer transition-all duration-200 gap-3 group ${this
|
||||
class="w-full h-full p-3 flex flex-col items-center justify-between rounded-xl border cursor-pointer transition-all duration-200 active:scale-95 gap-3 group ${this
|
||||
.selected
|
||||
? "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.3)]"
|
||||
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 hover:-translate-y-1 active:scale-95"}"
|
||||
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 hover:-translate-y-1"}"
|
||||
>
|
||||
${this.isLoading
|
||||
? html`<div
|
||||
@@ -79,6 +101,8 @@ export class MapDisplay extends LitElement {
|
||||
<img
|
||||
src="${this.mapWebpPath}"
|
||||
alt="${this.translation || this.mapName}"
|
||||
draggable="false"
|
||||
@dragstart=${this.preventImageDrag}
|
||||
class="w-full h-full object-cover ${this.selected
|
||||
? "opacity-100"
|
||||
: "opacity-80"} group-hover:opacity-100 transition-opacity duration-200"
|
||||
|
||||
@@ -43,6 +43,10 @@ export class MapPicker extends LitElement {
|
||||
this.onSelectRandom?.();
|
||||
};
|
||||
|
||||
private preventImageDrag(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
private getWins(mapValue: GameMapType): Set<Difficulty> {
|
||||
return this.mapWins?.get(mapValue) ?? new Set();
|
||||
}
|
||||
@@ -54,7 +58,7 @@ export class MapPicker extends LitElement {
|
||||
return html`
|
||||
<div
|
||||
@click=${() => this.handleMapSelection(mapValue)}
|
||||
class="cursor-pointer transition-transform duration-200 active:scale-95"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<map-display
|
||||
.mapKey=${mapKey}
|
||||
@@ -89,7 +93,7 @@ export class MapPicker extends LitElement {
|
||||
|
||||
private renderFeaturedMaps() {
|
||||
let featuredMapList = featuredMaps;
|
||||
if (!featuredMapList.includes(this.selectedMap)) {
|
||||
if (!this.useRandomMap && !featuredMapList.includes(this.selectedMap)) {
|
||||
featuredMapList = [this.selectedMap, ...featuredMaps];
|
||||
}
|
||||
return html`<div class="w-full">
|
||||
@@ -117,7 +121,7 @@ export class MapPicker extends LitElement {
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected=${!this.showAllMaps}
|
||||
class="px-3 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all ${this
|
||||
class="px-3 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all active:scale-95 ${this
|
||||
.showAllMaps
|
||||
? "text-white/60 hover:text-white"
|
||||
: "bg-blue-500/20 text-blue-100 shadow-[0_0_12px_rgba(59,130,246,0.2)]"}"
|
||||
@@ -129,7 +133,7 @@ export class MapPicker extends LitElement {
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected=${this.showAllMaps}
|
||||
class="px-3 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all ${this
|
||||
class="px-3 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all active:scale-95 ${this
|
||||
.showAllMaps
|
||||
? "bg-blue-500/20 text-blue-100 shadow-[0_0_12px_rgba(59,130,246,0.2)]"
|
||||
: "text-white/60 hover:text-white"}"
|
||||
@@ -152,27 +156,30 @@ export class MapPicker extends LitElement {
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<button
|
||||
class="relative group rounded-xl border transition-all duration-200 overflow-hidden flex flex-col items-stretch ${this
|
||||
type="button"
|
||||
class="w-full h-full p-3 flex flex-col items-center justify-between rounded-xl border cursor-pointer transition-all duration-200 active:scale-95 gap-3 group ${this
|
||||
.useRandomMap
|
||||
? "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.3)]"
|
||||
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20"}"
|
||||
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 hover:-translate-y-1"}"
|
||||
@click=${this.handleSelectRandomMap}
|
||||
>
|
||||
<div
|
||||
class="aspect-[2/1] w-full relative overflow-hidden bg-black/20"
|
||||
class="w-full aspect-[2/1] relative overflow-hidden rounded-lg bg-black/20"
|
||||
>
|
||||
<img
|
||||
src=${randomMap}
|
||||
alt=${translateText("map.random")}
|
||||
class="w-full h-full object-cover opacity-60 group-hover:opacity-100 transition-opacity"
|
||||
draggable="false"
|
||||
@dragstart=${this.preventImageDrag}
|
||||
class="w-full h-full object-cover ${this.useRandomMap
|
||||
? "opacity-100"
|
||||
: "opacity-80"} group-hover:opacity-100 transition-opacity duration-200"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-3 text-center border-t border-white/5">
|
||||
<div
|
||||
class="text-xs font-bold text-white uppercase tracking-wider break-words hyphens-auto"
|
||||
>
|
||||
${translateText("map.random")}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs font-bold text-white uppercase tracking-wider text-center leading-tight break-words hyphens-auto"
|
||||
>
|
||||
${translateText("map.random")}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -15,13 +15,13 @@ export interface ModalHeaderProps {
|
||||
|
||||
const DEFAULT_WRAPPER_CLASS = "flex flex-wrap items-center gap-2 shrink-0";
|
||||
const DEFAULT_DIVIDER_CLASS = "border-b border-white/10";
|
||||
const DEFAULT_PADDING_CLASS = "p-6";
|
||||
const DEFAULT_PADDING_CLASS = "p-4 lg:p-6";
|
||||
const DEFAULT_LEFT_CLASS = "flex items-center gap-4 flex-1";
|
||||
const DEFAULT_BUTTON_CLASS =
|
||||
"group flex items-center justify-center w-10 h-10 rounded-full shrink-0 " +
|
||||
"bg-white/5 hover:bg-white/10 transition-all border border-white/10";
|
||||
const DEFAULT_TITLE_CLASS =
|
||||
"text-white text-xl sm:text-2xl md:text-3xl font-bold uppercase " +
|
||||
"text-white text-xl lg:text-2xl font-bold uppercase " +
|
||||
"tracking-widest break-words hyphens-auto";
|
||||
|
||||
const withClasses = (...classes: Array<string | undefined>) =>
|
||||
|
||||
@@ -7,10 +7,12 @@ import { FrameProfiler } from "./FrameProfiler";
|
||||
import { TransformHandler } from "./TransformHandler";
|
||||
import { UIState } from "./UIState";
|
||||
import { AlertFrame } from "./layers/AlertFrame";
|
||||
import { AttacksDisplay } from "./layers/AttacksDisplay";
|
||||
import { BuildMenu } from "./layers/BuildMenu";
|
||||
import { ChatDisplay } from "./layers/ChatDisplay";
|
||||
import { ChatModal } from "./layers/ChatModal";
|
||||
import { ControlPanel } from "./layers/ControlPanel";
|
||||
import { CoordinateGridLayer } from "./layers/CoordinateGridLayer";
|
||||
import { DynamicUILayer } from "./layers/DynamicUILayer";
|
||||
import { EmojiTable } from "./layers/EmojiTable";
|
||||
import { EventsDisplay } from "./layers/EventsDisplay";
|
||||
@@ -53,11 +55,13 @@ export function createRenderer(
|
||||
const transformHandler = new TransformHandler(game, eventBus, canvas);
|
||||
const userSettings = new UserSettings();
|
||||
|
||||
const uiState = {
|
||||
const uiState: UIState = {
|
||||
attackRatio: 20,
|
||||
ghostStructure: null,
|
||||
overlappingRailroads: [],
|
||||
ghostRailPaths: [],
|
||||
rocketDirectionUp: true,
|
||||
} as UIState;
|
||||
};
|
||||
|
||||
//hide when the game renders
|
||||
const startingModal = document.querySelector(
|
||||
@@ -97,6 +101,7 @@ export function createRenderer(
|
||||
console.error("GameLeftSidebar element not found in the DOM");
|
||||
}
|
||||
gameLeftSidebar.game = game;
|
||||
gameLeftSidebar.eventBus = eventBus;
|
||||
|
||||
const teamStats = document.querySelector("team-stats") as TeamStats;
|
||||
if (!teamStats || !(teamStats instanceof TeamStats)) {
|
||||
@@ -123,6 +128,16 @@ export function createRenderer(
|
||||
eventsDisplay.game = game;
|
||||
eventsDisplay.uiState = uiState;
|
||||
|
||||
const attacksDisplay = document.querySelector(
|
||||
"attacks-display",
|
||||
) as AttacksDisplay;
|
||||
if (!(attacksDisplay instanceof AttacksDisplay)) {
|
||||
console.error("attacks display not found");
|
||||
}
|
||||
attacksDisplay.eventBus = eventBus;
|
||||
attacksDisplay.game = game;
|
||||
attacksDisplay.uiState = uiState;
|
||||
|
||||
const chatDisplay = document.querySelector("chat-display") as ChatDisplay;
|
||||
if (!(chatDisplay instanceof ChatDisplay)) {
|
||||
console.error("chat display not found");
|
||||
@@ -235,6 +250,7 @@ export function createRenderer(
|
||||
console.error("spawn timer not found");
|
||||
}
|
||||
spawnTimer.game = game;
|
||||
spawnTimer.eventBus = eventBus;
|
||||
spawnTimer.transformHandler = transformHandler;
|
||||
|
||||
const immunityTimer = document.querySelector(
|
||||
@@ -244,6 +260,7 @@ export function createRenderer(
|
||||
console.error("immunity timer not found");
|
||||
}
|
||||
immunityTimer.game = game;
|
||||
immunityTimer.eventBus = eventBus;
|
||||
|
||||
const inGameHeaderAd = document.querySelector(
|
||||
"in-game-header-ad",
|
||||
@@ -266,6 +283,7 @@ export function createRenderer(
|
||||
new TerrainLayer(game, transformHandler),
|
||||
new TerritoryLayer(game, eventBus, transformHandler, userSettings),
|
||||
new RailroadLayer(game, eventBus, transformHandler, uiState),
|
||||
new CoordinateGridLayer(game, eventBus, transformHandler),
|
||||
structureLayer,
|
||||
samRadiusLayer,
|
||||
new UnitLayer(game, eventBus, transformHandler),
|
||||
@@ -276,6 +294,7 @@ export function createRenderer(
|
||||
new DynamicUILayer(game, transformHandler, eventBus),
|
||||
new NameLayer(game, transformHandler, eventBus),
|
||||
eventsDisplay,
|
||||
attacksDisplay,
|
||||
chatDisplay,
|
||||
buildMenu,
|
||||
new MainRadialMenu(
|
||||
@@ -322,6 +341,8 @@ export function createRenderer(
|
||||
export class GameRenderer {
|
||||
private context: CanvasRenderingContext2D;
|
||||
private layerTickState = new Map<Layer, { lastTickAtMs: number }>();
|
||||
private renderFramesSinceLastTick: number = 0;
|
||||
private renderLayerDurationsSinceLastTick: Record<string, number> = {};
|
||||
|
||||
constructor(
|
||||
private game: GameView,
|
||||
@@ -378,7 +399,10 @@ export class GameRenderer {
|
||||
}
|
||||
|
||||
renderGame() {
|
||||
FrameProfiler.clear();
|
||||
const shouldProfileFrame = FrameProfiler.isEnabled();
|
||||
if (shouldProfileFrame) {
|
||||
FrameProfiler.clear();
|
||||
}
|
||||
const start = performance.now();
|
||||
// Set background
|
||||
this.context.fillStyle = this.game
|
||||
@@ -412,9 +436,16 @@ export class GameRenderer {
|
||||
isTransformActive,
|
||||
);
|
||||
|
||||
const layerStart = FrameProfiler.start();
|
||||
layer.renderLayer?.(this.context);
|
||||
FrameProfiler.end(layer.constructor?.name ?? "UnknownLayer", layerStart);
|
||||
if (shouldProfileFrame) {
|
||||
const layerStart = FrameProfiler.start();
|
||||
layer.renderLayer?.(this.context);
|
||||
FrameProfiler.end(
|
||||
layer.constructor?.name ?? "UnknownLayer",
|
||||
layerStart,
|
||||
);
|
||||
} else {
|
||||
layer.renderLayer?.(this.context);
|
||||
}
|
||||
}
|
||||
handleTransformState(false, isTransformActive); // Ensure context is clean after rendering
|
||||
this.transformHandler.resetChanged();
|
||||
@@ -422,8 +453,15 @@ export class GameRenderer {
|
||||
requestAnimationFrame(() => this.renderGame());
|
||||
const duration = performance.now() - start;
|
||||
|
||||
const layerDurations = FrameProfiler.consume();
|
||||
this.performanceOverlay.updateFrameMetrics(duration, layerDurations);
|
||||
if (shouldProfileFrame) {
|
||||
const layerDurations = FrameProfiler.consume();
|
||||
this.renderFramesSinceLastTick++;
|
||||
for (const [name, ms] of Object.entries(layerDurations)) {
|
||||
this.renderLayerDurationsSinceLastTick[name] =
|
||||
(this.renderLayerDurationsSinceLastTick[name] ?? 0) + ms;
|
||||
}
|
||||
this.performanceOverlay.updateFrameMetrics(duration, layerDurations);
|
||||
}
|
||||
|
||||
if (duration > 50) {
|
||||
console.warn(
|
||||
@@ -434,6 +472,18 @@ export class GameRenderer {
|
||||
|
||||
tick() {
|
||||
const nowMs = performance.now();
|
||||
const shouldProfileTick = FrameProfiler.isEnabled();
|
||||
|
||||
if (shouldProfileTick) {
|
||||
this.performanceOverlay.updateRenderPerTickMetrics(
|
||||
this.renderFramesSinceLastTick,
|
||||
this.renderLayerDurationsSinceLastTick,
|
||||
);
|
||||
this.renderFramesSinceLastTick = 0;
|
||||
this.renderLayerDurationsSinceLastTick = {};
|
||||
}
|
||||
|
||||
const tickLayerDurations: Record<string, number> = {};
|
||||
|
||||
for (const layer of this.layers) {
|
||||
if (!layer.tick) {
|
||||
@@ -453,7 +503,17 @@ export class GameRenderer {
|
||||
state.lastTickAtMs = nowMs;
|
||||
this.layerTickState.set(layer, state);
|
||||
|
||||
const tickStart = shouldProfileTick ? performance.now() : 0;
|
||||
layer.tick();
|
||||
if (shouldProfileTick && tickStart !== 0) {
|
||||
const duration = performance.now() - tickStart;
|
||||
const label = layer.constructor?.name ?? "UnknownLayer";
|
||||
tickLayerDurations[label] = (tickLayerDurations[label] ?? 0) + duration;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldProfileTick) {
|
||||
this.performanceOverlay.updateTickLayerMetrics(tickLayerDurations);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AllPlayers, nukeTypes } from "../../core/game/Game";
|
||||
import { AllPlayers, Nukes } from "../../core/game/Game";
|
||||
import { GameView, PlayerView } from "../../core/game/GameView";
|
||||
import allianceIcon from "/images/AllianceIcon.svg?url";
|
||||
import allianceIconFaded from "/images/AllianceIconFaded.svg?url";
|
||||
@@ -134,7 +134,7 @@ export function getPlayerIcons(
|
||||
}
|
||||
|
||||
// Nuke icon (different color depending on whether the local player is the target)
|
||||
const nukesSentByOtherPlayer = game.units(...nukeTypes).filter((unit) => {
|
||||
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();
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { UnitType } from "../../core/game/Game";
|
||||
import { PlayerBuildableUnitType } from "../../core/game/Game";
|
||||
import { TileRef } from "../../core/game/GameMap";
|
||||
|
||||
export interface UIState {
|
||||
attackRatio: number;
|
||||
ghostStructure: UnitType | null;
|
||||
ghostStructure: PlayerBuildableUnitType | null;
|
||||
overlappingRailroads: number[];
|
||||
ghostRailPaths: TileRef[][];
|
||||
rocketDirectionUp: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { MessageType, PlayerType, UnitType } from "../../../core/game/Game";
|
||||
import {
|
||||
AttackUpdate,
|
||||
GameUpdateType,
|
||||
UnitIncomingUpdate,
|
||||
} from "../../../core/game/GameUpdates";
|
||||
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
|
||||
import {
|
||||
CancelAttackIntentEvent,
|
||||
CancelBoatIntentEvent,
|
||||
SendAttackIntentEvent,
|
||||
} from "../../Transport";
|
||||
import { renderTroops, translateText } from "../../Utils";
|
||||
import { getColoredSprite } from "../SpriteLoader";
|
||||
import { UIState } from "../UIState";
|
||||
import { Layer } from "./Layer";
|
||||
import {
|
||||
GoToPlayerEvent,
|
||||
GoToPositionEvent,
|
||||
GoToUnitEvent,
|
||||
} from "./Leaderboard";
|
||||
import swordIcon from "/images/SwordIcon.svg?url";
|
||||
|
||||
@customElement("attacks-display")
|
||||
export class AttacksDisplay extends LitElement implements Layer {
|
||||
public eventBus: EventBus;
|
||||
public game: GameView;
|
||||
public uiState: UIState;
|
||||
|
||||
private active: boolean = false;
|
||||
private incomingBoatIDs: Set<number> = new Set();
|
||||
private spriteDataURLCache: Map<string, string> = new Map();
|
||||
@state() private _isVisible: boolean = false;
|
||||
@state() private incomingAttacks: AttackUpdate[] = [];
|
||||
@state() private outgoingAttacks: AttackUpdate[] = [];
|
||||
@state() private outgoingLandAttacks: AttackUpdate[] = [];
|
||||
@state() private outgoingBoats: UnitView[] = [];
|
||||
@state() private incomingBoats: UnitView[] = [];
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
init() {}
|
||||
|
||||
tick() {
|
||||
this.active = true;
|
||||
|
||||
if (!this._isVisible && !this.game.inSpawnPhase()) {
|
||||
this._isVisible = true;
|
||||
}
|
||||
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer || !myPlayer.isAlive()) {
|
||||
if (this._isVisible) {
|
||||
this._isVisible = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Track incoming boat unit IDs from UnitIncoming events
|
||||
const updates = this.game.updatesSinceLastTick();
|
||||
if (updates) {
|
||||
for (const event of updates[
|
||||
GameUpdateType.UnitIncoming
|
||||
] as UnitIncomingUpdate[]) {
|
||||
if (
|
||||
event.playerID === myPlayer.smallID() &&
|
||||
event.messageType === MessageType.NAVAL_INVASION_INBOUND
|
||||
) {
|
||||
this.incomingBoatIDs.add(event.unitID);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve incoming boats from tracked IDs, remove inactive ones
|
||||
const resolvedIncomingBoats: UnitView[] = [];
|
||||
for (const unitID of this.incomingBoatIDs) {
|
||||
const unit = this.game.unit(unitID);
|
||||
if (unit && unit.isActive() && unit.type() === UnitType.TransportShip) {
|
||||
resolvedIncomingBoats.push(unit);
|
||||
} else {
|
||||
this.incomingBoatIDs.delete(unitID);
|
||||
}
|
||||
}
|
||||
this.incomingBoats = resolvedIncomingBoats;
|
||||
|
||||
this.incomingAttacks = myPlayer.incomingAttacks().filter((a) => {
|
||||
const t = (this.game.playerBySmallID(a.attackerID) as PlayerView).type();
|
||||
return t !== PlayerType.Bot;
|
||||
});
|
||||
|
||||
this.outgoingAttacks = myPlayer
|
||||
.outgoingAttacks()
|
||||
.filter((a) => a.targetID !== 0);
|
||||
|
||||
this.outgoingLandAttacks = myPlayer
|
||||
.outgoingAttacks()
|
||||
.filter((a) => a.targetID === 0);
|
||||
|
||||
this.outgoingBoats = myPlayer
|
||||
.units()
|
||||
.filter((u) => u.type() === UnitType.TransportShip);
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
renderLayer(): void {}
|
||||
|
||||
private renderButton(options: {
|
||||
content: any;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
translate?: boolean;
|
||||
hidden?: boolean;
|
||||
}) {
|
||||
const {
|
||||
content,
|
||||
onClick,
|
||||
className = "",
|
||||
disabled = false,
|
||||
translate = true,
|
||||
hidden = false,
|
||||
} = options;
|
||||
|
||||
if (hidden) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<button
|
||||
class="${className}"
|
||||
@click=${onClick}
|
||||
?disabled=${disabled}
|
||||
?translate=${translate}
|
||||
>
|
||||
${content}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private emitCancelAttackIntent(id: string) {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer) return;
|
||||
this.eventBus.emit(new CancelAttackIntentEvent(id));
|
||||
}
|
||||
|
||||
private emitBoatCancelIntent(id: number) {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer) return;
|
||||
this.eventBus.emit(new CancelBoatIntentEvent(id));
|
||||
}
|
||||
|
||||
private emitGoToPlayerEvent(attackerID: number) {
|
||||
const attacker = this.game.playerBySmallID(attackerID) as PlayerView;
|
||||
this.eventBus.emit(new GoToPlayerEvent(attacker));
|
||||
}
|
||||
|
||||
private getBoatSpriteDataURL(unit: UnitView): string {
|
||||
const owner = unit.owner();
|
||||
const key = `boat-${owner.id()}`;
|
||||
const cached = this.spriteDataURLCache.get(key);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const canvas = getColoredSprite(unit, this.game.config().theme());
|
||||
const dataURL = canvas.toDataURL();
|
||||
this.spriteDataURLCache.set(key, dataURL);
|
||||
return dataURL;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private async attackWarningOnClick(attack: AttackUpdate) {
|
||||
const playerView = this.game.playerBySmallID(attack.attackerID);
|
||||
if (playerView !== undefined) {
|
||||
if (playerView instanceof PlayerView) {
|
||||
const averagePosition = await playerView.attackAveragePosition(
|
||||
attack.attackerID,
|
||||
attack.id,
|
||||
);
|
||||
|
||||
if (averagePosition === null) {
|
||||
this.emitGoToPlayerEvent(attack.attackerID);
|
||||
} else {
|
||||
this.eventBus.emit(
|
||||
new GoToPositionEvent(averagePosition.x, averagePosition.y),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.emitGoToPlayerEvent(attack.attackerID);
|
||||
}
|
||||
}
|
||||
|
||||
private handleRetaliate(attack: AttackUpdate) {
|
||||
const attacker = this.game.playerBySmallID(attack.attackerID) as PlayerView;
|
||||
if (!attacker) return;
|
||||
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer) return;
|
||||
|
||||
const counterTroops = Math.min(
|
||||
attack.troops,
|
||||
this.uiState.attackRatio * myPlayer.troops(),
|
||||
);
|
||||
this.eventBus.emit(new SendAttackIntentEvent(attacker.id(), counterTroops));
|
||||
}
|
||||
|
||||
private renderIncomingAttacks() {
|
||||
if (this.incomingAttacks.length === 0) return html``;
|
||||
|
||||
return this.incomingAttacks.map(
|
||||
(attack) => html`
|
||||
<div
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs min-[1200px]:rounded-lg sm:rounded-r-lg px-1.5 py-0.5 overflow-hidden"
|
||||
>
|
||||
${this.renderButton({
|
||||
content: html`<img
|
||||
src="${swordIcon}"
|
||||
class="h-4 w-4 inline-block"
|
||||
style="filter: brightness(0) saturate(100%) invert(27%) sepia(91%) saturate(4551%) hue-rotate(348deg) brightness(89%) contrast(97%)"
|
||||
/>
|
||||
<span class="inline-block min-w-[3rem] text-right"
|
||||
>${renderTroops(attack.troops)}</span
|
||||
>
|
||||
<span class="truncate ml-1"
|
||||
>${(
|
||||
this.game.playerBySmallID(attack.attackerID) as PlayerView
|
||||
)?.name()}</span
|
||||
>
|
||||
${attack.retreating
|
||||
? `(${translateText("events_display.retreating")}...)`
|
||||
: ""} `,
|
||||
onClick: () => this.attackWarningOnClick(attack),
|
||||
className:
|
||||
"text-left text-red-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
|
||||
translate: false,
|
||||
})}
|
||||
${!attack.retreating
|
||||
? this.renderButton({
|
||||
content: html`<img
|
||||
src="${swordIcon}"
|
||||
class="h-4 w-4"
|
||||
style="filter: brightness(0) saturate(100%) invert(27%) sepia(91%) saturate(4551%) hue-rotate(348deg) brightness(89%) contrast(97%)"
|
||||
/>`,
|
||||
onClick: () => this.handleRetaliate(attack),
|
||||
className:
|
||||
"ml-auto inline-flex items-center justify-center cursor-pointer bg-red-900/50 hover:bg-red-800/70 rounded-lg px-1.5 py-1 border border-red-700/50",
|
||||
translate: false,
|
||||
})
|
||||
: ""}
|
||||
</div>
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
private renderOutgoingAttacks() {
|
||||
if (this.outgoingAttacks.length === 0) return html``;
|
||||
|
||||
return this.outgoingAttacks.map(
|
||||
(attack) => html`
|
||||
<div
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs min-[1200px]:rounded-lg sm:rounded-r-lg px-1.5 py-0.5 overflow-hidden"
|
||||
>
|
||||
${this.renderButton({
|
||||
content: html`<img
|
||||
src="${swordIcon}"
|
||||
class="h-4 w-4 inline-block"
|
||||
style="filter: invert(1)"
|
||||
/>
|
||||
<span class="inline-block min-w-[3rem] text-right"
|
||||
>${renderTroops(attack.troops)}</span
|
||||
>
|
||||
<span class="truncate ml-1"
|
||||
>${(
|
||||
this.game.playerBySmallID(attack.targetID) as PlayerView
|
||||
)?.name()}</span
|
||||
> `,
|
||||
onClick: async () => this.attackWarningOnClick(attack),
|
||||
className:
|
||||
"text-left text-blue-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
|
||||
translate: false,
|
||||
})}
|
||||
${!attack.retreating
|
||||
? this.renderButton({
|
||||
content: "❌",
|
||||
onClick: () => this.emitCancelAttackIntent(attack.id),
|
||||
className: "ml-auto text-left shrink-0",
|
||||
disabled: attack.retreating,
|
||||
})
|
||||
: html`<span class="ml-auto truncate text-blue-400"
|
||||
>(${translateText("events_display.retreating")}...)</span
|
||||
>`}
|
||||
</div>
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
private renderOutgoingLandAttacks() {
|
||||
if (this.outgoingLandAttacks.length === 0) return html``;
|
||||
|
||||
return this.outgoingLandAttacks.map(
|
||||
(landAttack) => html`
|
||||
<div
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs min-[1200px]:rounded-lg sm:rounded-r-lg px-1.5 py-0.5 overflow-hidden"
|
||||
>
|
||||
${this.renderButton({
|
||||
content: html`<img
|
||||
src="${swordIcon}"
|
||||
class="h-4 w-4 inline-block"
|
||||
style="filter: invert(1)"
|
||||
/>
|
||||
<span class="inline-block min-w-[3rem] text-right"
|
||||
>${renderTroops(landAttack.troops)}</span
|
||||
>
|
||||
${translateText("help_modal.ui_wilderness")}`,
|
||||
className:
|
||||
"text-left text-gray-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
|
||||
translate: false,
|
||||
})}
|
||||
${!landAttack.retreating
|
||||
? this.renderButton({
|
||||
content: "❌",
|
||||
onClick: () => this.emitCancelAttackIntent(landAttack.id),
|
||||
className: "ml-auto text-left shrink-0",
|
||||
disabled: landAttack.retreating,
|
||||
})
|
||||
: html`<span class="ml-auto truncate text-blue-400"
|
||||
>(${translateText("events_display.retreating")}...)</span
|
||||
>`}
|
||||
</div>
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
private getBoatTargetName(boat: UnitView): string {
|
||||
const target = boat.targetTile();
|
||||
if (target === undefined) return "";
|
||||
const ownerID = this.game.ownerID(target);
|
||||
if (ownerID === 0) return "";
|
||||
const player = this.game.playerBySmallID(ownerID) as PlayerView;
|
||||
return player?.name() ?? "";
|
||||
}
|
||||
|
||||
private renderBoatIcon(boat: UnitView) {
|
||||
const dataURL = this.getBoatSpriteDataURL(boat);
|
||||
if (!dataURL) return html``;
|
||||
return html`<img
|
||||
src="${dataURL}"
|
||||
class="h-5 w-5 inline-block"
|
||||
style="image-rendering: pixelated"
|
||||
/>`;
|
||||
}
|
||||
|
||||
private renderBoats() {
|
||||
if (this.outgoingBoats.length === 0) return html``;
|
||||
|
||||
return this.outgoingBoats.map(
|
||||
(boat) => html`
|
||||
<div
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs min-[1200px]:rounded-lg sm:rounded-r-lg px-1.5 py-0.5 overflow-hidden"
|
||||
>
|
||||
${this.renderButton({
|
||||
content: html`${this.renderBoatIcon(boat)}
|
||||
<span class="inline-block min-w-[3rem] text-right"
|
||||
>${renderTroops(boat.troops())}</span
|
||||
>
|
||||
<span class="truncate text-xs ml-1"
|
||||
>${this.getBoatTargetName(boat)}</span
|
||||
>`,
|
||||
onClick: () => this.eventBus.emit(new GoToUnitEvent(boat)),
|
||||
className:
|
||||
"text-left text-blue-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
|
||||
translate: false,
|
||||
})}
|
||||
${!boat.retreating()
|
||||
? this.renderButton({
|
||||
content: "❌",
|
||||
onClick: () => this.emitBoatCancelIntent(boat.id()),
|
||||
className: "ml-auto text-left shrink-0",
|
||||
disabled: boat.retreating(),
|
||||
})
|
||||
: html`<span class="ml-auto truncate text-blue-400"
|
||||
>(${translateText("events_display.retreating")}...)</span
|
||||
>`}
|
||||
</div>
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
private renderIncomingBoats() {
|
||||
if (this.incomingBoats.length === 0) return html``;
|
||||
|
||||
return this.incomingBoats.map(
|
||||
(boat) => html`
|
||||
<div
|
||||
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs min-[1200px]:rounded-lg sm:rounded-r-lg px-1.5 py-0.5 overflow-hidden"
|
||||
>
|
||||
${this.renderButton({
|
||||
content: html`${this.renderBoatIcon(boat)}
|
||||
<span class="inline-block min-w-[3rem] text-right"
|
||||
>${renderTroops(boat.troops())}</span
|
||||
>
|
||||
<span class="truncate text-xs ml-1"
|
||||
>${boat.owner()?.name()}</span
|
||||
>`,
|
||||
onClick: () => this.eventBus.emit(new GoToUnitEvent(boat)),
|
||||
className:
|
||||
"text-left text-red-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
|
||||
translate: false,
|
||||
})}
|
||||
</div>
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.active || !this._isVisible) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const hasAnything =
|
||||
this.outgoingAttacks.length > 0 ||
|
||||
this.outgoingLandAttacks.length > 0 ||
|
||||
this.outgoingBoats.length > 0 ||
|
||||
this.incomingAttacks.length > 0 ||
|
||||
this.incomingBoats.length > 0;
|
||||
|
||||
if (!hasAnything) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="w-full mb-1 mt-1 sm:mt-0 pointer-events-auto grid grid-cols-2 sm:grid-cols-1 gap-1 text-white text-sm lg:text-base"
|
||||
>
|
||||
${this.renderOutgoingAttacks()} ${this.renderOutgoingLandAttacks()}
|
||||
${this.renderBoats()} ${this.renderIncomingAttacks()}
|
||||
${this.renderIncomingBoats()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { translateText } from "../../../client/Utils";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import {
|
||||
BuildableUnit,
|
||||
BuildMenus,
|
||||
Gold,
|
||||
PlayerActions,
|
||||
PlayerBuildableUnitType,
|
||||
UnitType,
|
||||
} from "../../../core/game/Game";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
@@ -37,7 +38,7 @@ import samlauncherIcon from "/images/SamLauncherIconWhite.svg?url";
|
||||
import shieldIcon from "/images/ShieldIconWhite.svg?url";
|
||||
|
||||
export interface BuildItemDisplay {
|
||||
unitType: UnitType;
|
||||
unitType: PlayerBuildableUnitType;
|
||||
icon: string;
|
||||
description?: string;
|
||||
key?: string;
|
||||
@@ -88,7 +89,6 @@ export const buildTable: BuildItemDisplay[][] = [
|
||||
key: "unit_type.missile_silo",
|
||||
countable: true,
|
||||
},
|
||||
// needs new icon
|
||||
{
|
||||
unitType: UnitType.SAMLauncher,
|
||||
icon: samlauncherIcon,
|
||||
@@ -128,7 +128,7 @@ export class BuildMenu extends LitElement implements Layer {
|
||||
public eventBus: EventBus;
|
||||
public uiState: UIState;
|
||||
private clickedTile: TileRef;
|
||||
public playerActions: PlayerActions | null;
|
||||
public playerBuildables: BuildableUnit[] | null = null;
|
||||
private filteredBuildTable: BuildItemDisplay[][] = buildTable;
|
||||
public transformHandler: TransformHandler;
|
||||
|
||||
@@ -359,19 +359,15 @@ export class BuildMenu extends LitElement implements Layer {
|
||||
private _hidden = true;
|
||||
|
||||
public canBuildOrUpgrade(item: BuildItemDisplay): boolean {
|
||||
if (this.game?.myPlayer() === null || this.playerActions === null) {
|
||||
if (this.game?.myPlayer() === null || this.playerBuildables === null) {
|
||||
return false;
|
||||
}
|
||||
const buildableUnits = this.playerActions?.buildableUnits ?? [];
|
||||
const unit = buildableUnits.filter((u) => u.type === item.unitType);
|
||||
if (unit.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return unit[0].canBuild !== false || unit[0].canUpgrade !== false;
|
||||
const unit = this.playerBuildables.find((u) => u.type === item.unitType);
|
||||
return unit ? unit.canBuild !== false || unit.canUpgrade !== false : false;
|
||||
}
|
||||
|
||||
public cost(item: BuildItemDisplay): Gold {
|
||||
for (const bu of this.playerActions?.buildableUnits ?? []) {
|
||||
for (const bu of this.playerBuildables ?? []) {
|
||||
if (bu.type === item.unitType) {
|
||||
return bu.cost;
|
||||
}
|
||||
@@ -419,7 +415,7 @@ export class BuildMenu extends LitElement implements Layer {
|
||||
(row) => html`
|
||||
<div class="build-row">
|
||||
${row.map((item) => {
|
||||
const buildableUnit = this.playerActions?.buildableUnits.find(
|
||||
const buildableUnit = this.playerBuildables?.find(
|
||||
(bu) => bu.type === item.unitType,
|
||||
);
|
||||
if (buildableUnit === undefined) {
|
||||
@@ -492,13 +488,13 @@ export class BuildMenu extends LitElement implements Layer {
|
||||
private refresh() {
|
||||
this.game
|
||||
.myPlayer()
|
||||
?.actions(this.clickedTile)
|
||||
.then((actions) => {
|
||||
this.playerActions = actions;
|
||||
?.buildables(this.clickedTile, BuildMenus.types)
|
||||
.then((buildables) => {
|
||||
this.playerBuildables = buildables;
|
||||
this.requestUpdate();
|
||||
});
|
||||
|
||||
// removed disabled buildings from the buildtable
|
||||
// remove disabled buildings from the buildtable
|
||||
this.filteredBuildTable = this.getBuildableUnits();
|
||||
}
|
||||
|
||||
|
||||
@@ -260,8 +260,8 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
class="pointer-events-auto ${this._isVisible
|
||||
? "relative z-[60] w-full max-lg:landscape:fixed max-lg:landscape:bottom-0 max-lg:landscape:left-0 max-lg:landscape:w-1/2 max-lg:landscape:z-50 lg:max-w-[400px] text-sm lg:text-base bg-gray-800/70 p-1.5 pr-2 lg:p-5 shadow-lg lg:rounded-tr-xl min-[1200px]:rounded-xl backdrop-blur-sm"
|
||||
class="relative pointer-events-auto ${this._isVisible
|
||||
? "relative z-[60] w-full lg:max-w-[400px] text-sm lg:text-base bg-gray-800/70 p-1.5 pr-2 lg:p-5 shadow-lg sm:rounded-tr-lg min-[1200px]:rounded-lg backdrop-blur-xs"
|
||||
: "hidden"}"
|
||||
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
|
||||
>
|
||||
@@ -309,7 +309,7 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
</div>
|
||||
</div>
|
||||
<!-- Small red vertical bar indicator -->
|
||||
<div class="relative shrink-0">
|
||||
<div class="shrink-0">
|
||||
<div
|
||||
class="w-1.5 h-8 bg-white/20 rounded-full relative overflow-hidden"
|
||||
>
|
||||
@@ -318,32 +318,30 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
style="height: ${this.attackRatio * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
${this._touchDragging
|
||||
? html`
|
||||
<div
|
||||
class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 flex flex-col items-center pointer-events-auto z-[10000] bg-gray-800/80 backdrop-blur-sm rounded-lg p-2 w-12"
|
||||
style="height: 50vh;"
|
||||
@touchstart=${(e: TouchEvent) => this.handleBarTouch(e)}
|
||||
>
|
||||
<span
|
||||
class="text-red-400 text-sm font-bold mb-1"
|
||||
translate="no"
|
||||
>${(this.attackRatio * 100).toFixed(0)}%</span
|
||||
>
|
||||
<div
|
||||
class="attack-drag-bar flex-1 w-3 bg-white/20 rounded-full relative overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="absolute bottom-0 w-full bg-red-500 rounded-full"
|
||||
style="height: ${this.attackRatio * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${this._touchDragging
|
||||
? html`
|
||||
<div
|
||||
class="absolute bottom-full right-0 flex flex-col items-center pointer-events-auto z-[10000] bg-gray-800/70 backdrop-blur-xs rounded-tl-lg sm:rounded-lg p-2 w-12"
|
||||
style="height: 50vh;"
|
||||
@touchstart=${(e: TouchEvent) => this.handleBarTouch(e)}
|
||||
>
|
||||
<span class="text-red-400 text-sm font-bold mb-1" translate="no"
|
||||
>${(this.attackRatio * 100).toFixed(0)}%</span
|
||||
>
|
||||
<div
|
||||
class="attack-drag-bar flex-1 w-3 bg-white/20 rounded-full relative overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="absolute bottom-0 w-full bg-red-500 rounded-full"
|
||||
style="height: ${this.attackRatio * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
<!-- Attack ratio bar (desktop, always visible) -->
|
||||
<div class="hidden lg:block mt-2">
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { Cell } from "../../../core/game/Game";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import {
|
||||
AlternateViewEvent,
|
||||
ToggleCoordinateGridEvent,
|
||||
} from "../../InputHandler";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
const BASE_CELL_COUNT = 10;
|
||||
const MAX_COLUMNS = 50;
|
||||
const MIN_ROWS = 2;
|
||||
const LABEL_PADDING = 8;
|
||||
|
||||
const toAlphaLabel = (index: number): string => {
|
||||
let value = index;
|
||||
let label = "";
|
||||
do {
|
||||
label = String.fromCharCode(65 + (value % 26)) + label;
|
||||
value = Math.floor(value / 26) - 1;
|
||||
} while (value >= 0);
|
||||
return label;
|
||||
};
|
||||
|
||||
const computeGrid = (width: number, height: number) => {
|
||||
// Initial square-ish estimate
|
||||
let cellSize = Math.min(width, height) / BASE_CELL_COUNT;
|
||||
let rows = Math.max(1, Math.round(height / cellSize));
|
||||
let cols = Math.max(1, Math.round(width / cellSize));
|
||||
|
||||
// Cap columns and adjust rows accordingly
|
||||
if (cols > MAX_COLUMNS) {
|
||||
const maxRowsForCols = Math.floor((MAX_COLUMNS * height) / width);
|
||||
rows = Math.max(MIN_ROWS, Math.min(rows, maxRowsForCols));
|
||||
cols = MAX_COLUMNS;
|
||||
}
|
||||
|
||||
cellSize = Math.min(width / cols, height / rows);
|
||||
const fullCols = Math.max(1, Math.floor(width / cellSize));
|
||||
const fullRows = Math.max(1, Math.floor(height / cellSize));
|
||||
|
||||
const remainderX = Math.max(0, width - fullCols * cellSize);
|
||||
const remainderY = Math.max(0, height - fullRows * cellSize);
|
||||
|
||||
const hasExtraCol = remainderX > 0.001;
|
||||
const hasExtraRow = remainderY > 0.001;
|
||||
|
||||
const totalCols = fullCols + (hasExtraCol ? 1 : 0);
|
||||
const totalRows = fullRows + (hasExtraRow ? 1 : 0);
|
||||
|
||||
const lastColWidth = hasExtraCol ? remainderX : cellSize;
|
||||
const lastRowHeight = hasExtraRow ? remainderY : cellSize;
|
||||
|
||||
return {
|
||||
cellSize,
|
||||
rows: totalRows,
|
||||
cols: totalCols,
|
||||
fullCols,
|
||||
fullRows,
|
||||
lastColWidth,
|
||||
lastRowHeight,
|
||||
hasExtraCol,
|
||||
hasExtraRow,
|
||||
gridWidth: width,
|
||||
gridHeight: height,
|
||||
};
|
||||
};
|
||||
|
||||
export class CoordinateGridLayer implements Layer {
|
||||
private isVisible = false;
|
||||
private alternateView = false;
|
||||
private cachedGridCanvas: HTMLCanvasElement | null = null;
|
||||
private cachedGridContext: CanvasRenderingContext2D | null = null;
|
||||
private cachedGridKey = "";
|
||||
|
||||
constructor(
|
||||
private game: GameView,
|
||||
private eventBus: EventBus,
|
||||
private transformHandler: TransformHandler,
|
||||
) {}
|
||||
|
||||
init() {
|
||||
this.eventBus.on(ToggleCoordinateGridEvent, (event) => {
|
||||
this.isVisible = event.enabled;
|
||||
});
|
||||
this.eventBus.on(AlternateViewEvent, (event) => {
|
||||
this.alternateView = event.alternateView;
|
||||
});
|
||||
}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
renderLayer(context: CanvasRenderingContext2D) {
|
||||
if (!this.isVisible && !this.alternateView) return;
|
||||
|
||||
const width = this.game.width();
|
||||
const height = this.game.height();
|
||||
if (width <= 0 || height <= 0) return;
|
||||
const canvasWidth = context.canvas.width;
|
||||
const canvasHeight = context.canvas.height;
|
||||
|
||||
const cacheKey = this.buildCacheKey(
|
||||
width,
|
||||
height,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
);
|
||||
const cacheContext = this.ensureCacheContext(canvasWidth, canvasHeight);
|
||||
if (cacheContext === null || this.cachedGridCanvas === null) return;
|
||||
|
||||
if (this.cachedGridKey !== cacheKey) {
|
||||
cacheContext.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
this.drawGrid(cacheContext, width, height);
|
||||
this.cachedGridKey = cacheKey;
|
||||
}
|
||||
|
||||
context.drawImage(this.cachedGridCanvas, 0, 0);
|
||||
}
|
||||
|
||||
private ensureCacheContext(
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
): CanvasRenderingContext2D | null {
|
||||
this.cachedGridCanvas ??= document.createElement("canvas");
|
||||
|
||||
if (
|
||||
this.cachedGridCanvas.width !== canvasWidth ||
|
||||
this.cachedGridCanvas.height !== canvasHeight
|
||||
) {
|
||||
this.cachedGridCanvas.width = canvasWidth;
|
||||
this.cachedGridCanvas.height = canvasHeight;
|
||||
this.cachedGridContext = null;
|
||||
this.cachedGridKey = "";
|
||||
}
|
||||
|
||||
this.cachedGridContext ??= this.cachedGridCanvas.getContext("2d");
|
||||
|
||||
return this.cachedGridContext;
|
||||
}
|
||||
|
||||
private buildCacheKey(
|
||||
width: number,
|
||||
height: number,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
): string {
|
||||
const topLeft = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(0, 0),
|
||||
);
|
||||
const bottomRight = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(width, height),
|
||||
);
|
||||
return [
|
||||
width,
|
||||
height,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
this.transformHandler.scale.toFixed(4),
|
||||
topLeft.x.toFixed(2),
|
||||
topLeft.y.toFixed(2),
|
||||
bottomRight.x.toFixed(2),
|
||||
bottomRight.y.toFixed(2),
|
||||
].join("|");
|
||||
}
|
||||
|
||||
private drawGrid(
|
||||
context: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
const {
|
||||
cellSize,
|
||||
rows,
|
||||
cols,
|
||||
fullCols,
|
||||
fullRows,
|
||||
lastColWidth,
|
||||
lastRowHeight,
|
||||
hasExtraCol,
|
||||
hasExtraRow,
|
||||
gridWidth,
|
||||
gridHeight,
|
||||
} = computeGrid(width, height);
|
||||
const cellWidth = cellSize;
|
||||
const cellHeight = cellSize;
|
||||
const canvasWidth = context.canvas.width;
|
||||
const canvasHeight = context.canvas.height;
|
||||
|
||||
const mapTopScreenRaw = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(0, 0),
|
||||
).y;
|
||||
const mapBottomScreenRaw = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(0, height),
|
||||
).y;
|
||||
const mapLeftScreenRaw = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(0, 0),
|
||||
).x;
|
||||
const mapRightScreenRaw = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(width, 0),
|
||||
).x;
|
||||
|
||||
const mapTopScreen = Math.min(mapTopScreenRaw, mapBottomScreenRaw);
|
||||
const mapLeftScreen = Math.min(mapLeftScreenRaw, mapRightScreenRaw);
|
||||
const mapTopWorld = 0;
|
||||
const mapLeftWorld = 0;
|
||||
|
||||
context.save();
|
||||
context.strokeStyle = "rgba(255, 255, 255, 0.35)";
|
||||
context.lineWidth = 1.25;
|
||||
context.beginPath();
|
||||
|
||||
for (let col = 0; col <= fullCols; col++) {
|
||||
const worldX = col * cellWidth + mapLeftWorld;
|
||||
const screenX = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(worldX, mapTopWorld),
|
||||
).x;
|
||||
if (screenX < -1 || screenX > canvasWidth + 1) continue;
|
||||
const screenBottom = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(worldX, gridHeight),
|
||||
).y;
|
||||
context.moveTo(screenX, mapTopScreen);
|
||||
context.lineTo(screenX, screenBottom);
|
||||
}
|
||||
// Final vertical line at map right edge only if grid fits perfectly
|
||||
if (!hasExtraCol) {
|
||||
const mapRightLine = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(gridWidth, mapTopWorld),
|
||||
).x;
|
||||
context.moveTo(mapRightLine, mapTopScreen);
|
||||
context.lineTo(
|
||||
mapRightLine,
|
||||
this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(gridWidth, gridHeight),
|
||||
).y,
|
||||
);
|
||||
}
|
||||
|
||||
for (let row = 0; row <= fullRows; row++) {
|
||||
const worldY = row * cellHeight + mapTopWorld;
|
||||
const screenY = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(mapLeftWorld, worldY),
|
||||
).y;
|
||||
if (screenY < -1 || screenY > canvasHeight + 1) continue;
|
||||
const screenRight = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(gridWidth, worldY),
|
||||
).x;
|
||||
context.moveTo(mapLeftScreen, screenY);
|
||||
context.lineTo(screenRight, screenY);
|
||||
}
|
||||
// Final horizontal line at map bottom edge only if grid fits perfectly
|
||||
if (!hasExtraRow) {
|
||||
const mapBottomLine = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(mapLeftWorld, gridHeight),
|
||||
).y;
|
||||
context.moveTo(mapLeftScreen, mapBottomLine);
|
||||
context.lineTo(
|
||||
this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(gridWidth, gridHeight),
|
||||
).x,
|
||||
mapBottomLine,
|
||||
);
|
||||
}
|
||||
|
||||
context.stroke();
|
||||
|
||||
context.font = "12px monospace";
|
||||
|
||||
const drawLabel = (text: string, x: number, y: number) => {
|
||||
context.textAlign = "left";
|
||||
context.textBaseline = "top";
|
||||
context.fillStyle = "rgba(20, 20, 20, 0.9)";
|
||||
context.fillText(text, x, y);
|
||||
};
|
||||
|
||||
// Render per-cell labels (like A1, B1, etc.) at cell top-left
|
||||
const fontSize = Math.min(
|
||||
16,
|
||||
Math.max(9, 10 + (this.transformHandler.scale - 1) * 1.2),
|
||||
);
|
||||
context.font = `${fontSize}px monospace`;
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const rowLabel = toAlphaLabel(row);
|
||||
const startY = row * cellHeight;
|
||||
const rowHeight = row < fullRows ? cellHeight : lastRowHeight;
|
||||
const centerY = startY + rowHeight / 2;
|
||||
const screenY = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(0, centerY),
|
||||
).y;
|
||||
if (screenY < -LABEL_PADDING || screenY > canvasHeight + LABEL_PADDING)
|
||||
continue;
|
||||
|
||||
for (let col = 0; col < cols; col++) {
|
||||
const startX = col * cellWidth;
|
||||
const colWidth = col < fullCols ? cellWidth : lastColWidth;
|
||||
const centerX = startX + colWidth / 2;
|
||||
const screenX = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(centerX, centerY),
|
||||
).x;
|
||||
if (screenX < -LABEL_PADDING || screenX > canvasWidth + LABEL_PADDING)
|
||||
continue;
|
||||
|
||||
// Position at cell top-left in screen space
|
||||
const cellTopLeft = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(startX, startY),
|
||||
);
|
||||
drawLabel(
|
||||
`${rowLabel}${col + 1}`,
|
||||
cellTopLeft.x + LABEL_PADDING,
|
||||
cellTopLeft.y + LABEL_PADDING,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
context.restore();
|
||||
}
|
||||
}
|
||||
@@ -8,15 +8,13 @@ import {
|
||||
getMessageCategory,
|
||||
MessageCategory,
|
||||
MessageType,
|
||||
PlayerType,
|
||||
Tick,
|
||||
UnitType,
|
||||
} from "../../../core/game/Game";
|
||||
import {
|
||||
AllianceExpiredUpdate,
|
||||
AllianceExtensionUpdate,
|
||||
AllianceRequestReplyUpdate,
|
||||
AllianceRequestUpdate,
|
||||
AttackUpdate,
|
||||
BrokeAllianceUpdate,
|
||||
DisplayChatMessageUpdate,
|
||||
DisplayMessageUpdate,
|
||||
@@ -26,22 +24,16 @@ import {
|
||||
UnitIncomingUpdate,
|
||||
} from "../../../core/game/GameUpdates";
|
||||
import {
|
||||
CancelAttackIntentEvent,
|
||||
CancelBoatIntentEvent,
|
||||
SendAllianceExtensionIntentEvent,
|
||||
SendAllianceReplyIntentEvent,
|
||||
SendAttackIntentEvent,
|
||||
SendAllianceRejectIntentEvent,
|
||||
SendAllianceRequestIntentEvent,
|
||||
} from "../../Transport";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
|
||||
import { onlyImages } from "../../../core/Util";
|
||||
import { renderNumber, renderTroops } from "../../Utils";
|
||||
import {
|
||||
GoToPlayerEvent,
|
||||
GoToPositionEvent,
|
||||
GoToUnitEvent,
|
||||
} from "./Leaderboard";
|
||||
import { renderNumber } from "../../Utils";
|
||||
import { GoToPlayerEvent, GoToUnitEvent } from "./Leaderboard";
|
||||
|
||||
import { getMessageTypeClasses, translateText } from "../../Utils";
|
||||
import { UIState } from "../UIState";
|
||||
@@ -84,10 +76,6 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
|
||||
// allianceID -> last checked at tick
|
||||
private alliancesCheckedAt = new Map<number, Tick>();
|
||||
@state() private incomingAttacks: AttackUpdate[] = [];
|
||||
@state() private outgoingAttacks: AttackUpdate[] = [];
|
||||
@state() private outgoingLandAttacks: AttackUpdate[] = [];
|
||||
@state() private outgoingBoats: UnitView[] = [];
|
||||
@state() private _hidden: boolean = false;
|
||||
@state() private _isVisible: boolean = false;
|
||||
@state() private newEvents: number = 0;
|
||||
@@ -189,14 +177,15 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
[GameUpdateType.Emoji, this.onEmojiMessageEvent.bind(this)],
|
||||
[GameUpdateType.UnitIncoming, this.onUnitIncomingEvent.bind(this)],
|
||||
[GameUpdateType.AllianceExpired, this.onAllianceExpiredEvent.bind(this)],
|
||||
[
|
||||
GameUpdateType.AllianceExtension,
|
||||
this.onAllianceExtensionEvent.bind(this),
|
||||
],
|
||||
] as const;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.events = [];
|
||||
this.incomingAttacks = [];
|
||||
this.outgoingAttacks = [];
|
||||
this.outgoingBoats = [];
|
||||
}
|
||||
|
||||
init() {}
|
||||
@@ -254,24 +243,6 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
// Update attacks
|
||||
this.incomingAttacks = myPlayer.incomingAttacks().filter((a) => {
|
||||
const t = (this.game.playerBySmallID(a.attackerID) as PlayerView).type();
|
||||
return t !== PlayerType.Bot;
|
||||
});
|
||||
|
||||
this.outgoingAttacks = myPlayer
|
||||
.outgoingAttacks()
|
||||
.filter((a) => a.targetID !== 0);
|
||||
|
||||
this.outgoingLandAttacks = myPlayer
|
||||
.outgoingAttacks()
|
||||
.filter((a) => a.targetID === 0);
|
||||
|
||||
this.outgoingBoats = myPlayer
|
||||
.units()
|
||||
.filter((u) => u.type() === UnitType.TransportShip);
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
@@ -286,7 +257,11 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer?.isAlive()) return;
|
||||
|
||||
const currentAllianceIds = new Set<number>();
|
||||
|
||||
for (const alliance of myPlayer.alliances()) {
|
||||
currentAllianceIds.add(alliance.id);
|
||||
|
||||
if (
|
||||
alliance.expiresAt >
|
||||
this.game.ticks() + this.game.config().allianceExtensionPromptOffset()
|
||||
@@ -305,7 +280,6 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
this.alliancesCheckedAt.set(alliance.id, this.game.ticks());
|
||||
|
||||
const other = this.game.player(alliance.other) as PlayerView;
|
||||
if (!other.isAlive()) continue;
|
||||
|
||||
this.addEvent({
|
||||
description: translateText("events_display.about_to_expire", {
|
||||
@@ -340,6 +314,13 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
allianceID: alliance.id,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [allianceId] of this.alliancesCheckedAt) {
|
||||
if (!currentAllianceIds.has(allianceId)) {
|
||||
this.removeAllianceRenewalEvents(allianceId);
|
||||
this.alliancesCheckedAt.delete(allianceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private addEvent(event: GameEvent) {
|
||||
@@ -493,16 +474,14 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
className: "btn",
|
||||
action: () =>
|
||||
this.eventBus.emit(
|
||||
new SendAllianceReplyIntentEvent(requestor, recipient, true),
|
||||
new SendAllianceRequestIntentEvent(recipient, requestor),
|
||||
),
|
||||
},
|
||||
{
|
||||
text: translateText("events_display.reject_alliance"),
|
||||
className: "btn-info",
|
||||
action: () =>
|
||||
this.eventBus.emit(
|
||||
new SendAllianceReplyIntentEvent(requestor, recipient, false),
|
||||
),
|
||||
this.eventBus.emit(new SendAllianceRejectIntentEvent(requestor)),
|
||||
},
|
||||
],
|
||||
highlight: true,
|
||||
@@ -565,6 +544,7 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
if (!myPlayer) return;
|
||||
|
||||
this.removeAllianceRenewalEvents(update.allianceID);
|
||||
this.alliancesCheckedAt.delete(update.allianceID);
|
||||
this.requestUpdate();
|
||||
|
||||
const betrayed = this.game.playerBySmallID(update.betrayedID) as PlayerView;
|
||||
@@ -645,6 +625,13 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
});
|
||||
}
|
||||
|
||||
private onAllianceExtensionEvent(update: AllianceExtensionUpdate) {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer || myPlayer.smallID() !== update.playerID) return;
|
||||
this.removeAllianceRenewalEvents(update.allianceID);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
onTargetPlayerEvent(event: TargetPlayerUpdate) {
|
||||
const other = this.game.playerBySmallID(event.playerID) as PlayerView;
|
||||
const myPlayer = this.game.myPlayer() as PlayerView;
|
||||
@@ -664,28 +651,12 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
});
|
||||
}
|
||||
|
||||
emitCancelAttackIntent(id: string) {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer) return;
|
||||
this.eventBus.emit(new CancelAttackIntentEvent(id));
|
||||
}
|
||||
|
||||
emitBoatCancelIntent(id: number) {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer) return;
|
||||
this.eventBus.emit(new CancelBoatIntentEvent(id));
|
||||
}
|
||||
|
||||
emitGoToPlayerEvent(attackerID: number) {
|
||||
const attacker = this.game.playerBySmallID(attackerID) as PlayerView;
|
||||
if (!attacker) return;
|
||||
this.eventBus.emit(new GoToPlayerEvent(attacker));
|
||||
}
|
||||
|
||||
emitGoToPositionEvent(x: number, y: number) {
|
||||
this.eventBus.emit(new GoToPositionEvent(x, y));
|
||||
}
|
||||
|
||||
emitGoToUnitEvent(unit: UnitView) {
|
||||
this.eventBus.emit(new GoToUnitEvent(unit));
|
||||
}
|
||||
@@ -753,196 +724,6 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
: event.description;
|
||||
}
|
||||
|
||||
private async attackWarningOnClick(attack: AttackUpdate) {
|
||||
const playerView = this.game.playerBySmallID(attack.attackerID);
|
||||
if (playerView !== undefined) {
|
||||
if (playerView instanceof PlayerView) {
|
||||
const averagePosition = await playerView.attackAveragePosition(
|
||||
attack.attackerID,
|
||||
attack.id,
|
||||
);
|
||||
|
||||
if (averagePosition === null) {
|
||||
this.emitGoToPlayerEvent(attack.attackerID);
|
||||
} else {
|
||||
this.emitGoToPositionEvent(averagePosition.x, averagePosition.y);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.emitGoToPlayerEvent(attack.attackerID);
|
||||
}
|
||||
}
|
||||
|
||||
private handleRetaliate(attack: AttackUpdate) {
|
||||
const attacker = this.game.playerBySmallID(attack.attackerID) as PlayerView;
|
||||
if (!attacker) return;
|
||||
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer) return;
|
||||
|
||||
const counterTroops = Math.min(
|
||||
attack.troops,
|
||||
this.uiState.attackRatio * myPlayer.troops(),
|
||||
);
|
||||
this.eventBus.emit(new SendAttackIntentEvent(attacker.id(), counterTroops));
|
||||
}
|
||||
|
||||
private renderIncomingAttacks() {
|
||||
return html`
|
||||
${this.incomingAttacks.length > 0
|
||||
? html`
|
||||
<div class="flex flex-wrap gap-y-1 gap-x-2">
|
||||
${this.incomingAttacks.map(
|
||||
(attack) => html`
|
||||
<div class="inline-flex items-center gap-1">
|
||||
${this.renderButton({
|
||||
content: html`
|
||||
${renderTroops(attack.troops)}
|
||||
${(
|
||||
this.game.playerBySmallID(
|
||||
attack.attackerID,
|
||||
) as PlayerView
|
||||
)?.name()}
|
||||
${attack.retreating
|
||||
? `(${translateText("events_display.retreating")}...)`
|
||||
: ""}
|
||||
`,
|
||||
onClick: () => this.attackWarningOnClick(attack),
|
||||
className: "text-left text-red-400",
|
||||
translate: false,
|
||||
})}
|
||||
${!attack.retreating
|
||||
? this.renderButton({
|
||||
content: translateText("events_display.retaliate"),
|
||||
onClick: () => this.handleRetaliate(attack),
|
||||
className:
|
||||
"inline-block px-3 py-1 text-white rounded-sm text-md md:text-sm cursor-pointer transition-colors duration-300 bg-red-600 hover:bg-red-700",
|
||||
translate: true,
|
||||
})
|
||||
: ""}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderOutgoingAttacks() {
|
||||
return html`
|
||||
${this.outgoingAttacks.length > 0
|
||||
? html`
|
||||
<div class="flex flex-wrap gap-y-1 gap-x-2">
|
||||
${this.outgoingAttacks.map(
|
||||
(attack) => html`
|
||||
<div class="inline-flex items-center gap-1">
|
||||
${this.renderButton({
|
||||
content: html`
|
||||
${renderTroops(attack.troops)}
|
||||
${(
|
||||
this.game.playerBySmallID(
|
||||
attack.targetID,
|
||||
) as PlayerView
|
||||
)?.name()}
|
||||
`,
|
||||
onClick: async () => this.attackWarningOnClick(attack),
|
||||
className: "text-left text-blue-400",
|
||||
translate: false,
|
||||
})}
|
||||
${!attack.retreating
|
||||
? this.renderButton({
|
||||
content: "❌",
|
||||
onClick: () => this.emitCancelAttackIntent(attack.id),
|
||||
className: "text-left shrink-0",
|
||||
disabled: attack.retreating,
|
||||
})
|
||||
: html`<span class="shrink-0 text-blue-400"
|
||||
>(${translateText(
|
||||
"events_display.retreating",
|
||||
)}...)</span
|
||||
>`}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderOutgoingLandAttacks() {
|
||||
return html`
|
||||
${this.outgoingLandAttacks.length > 0
|
||||
? html`
|
||||
<div class="flex flex-wrap gap-y-1 gap-x-2">
|
||||
${this.outgoingLandAttacks.map(
|
||||
(landAttack) => html`
|
||||
<div class="inline-flex items-center gap-1">
|
||||
${this.renderButton({
|
||||
content: html`${renderTroops(landAttack.troops)}
|
||||
${translateText("help_modal.ui_wilderness")}`,
|
||||
className: "text-left text-gray-400",
|
||||
translate: false,
|
||||
})}
|
||||
${!landAttack.retreating
|
||||
? this.renderButton({
|
||||
content: "❌",
|
||||
onClick: () =>
|
||||
this.emitCancelAttackIntent(landAttack.id),
|
||||
className: "text-left shrink-0",
|
||||
disabled: landAttack.retreating,
|
||||
})
|
||||
: html`<span class="shrink-0 text-blue-400"
|
||||
>(${translateText(
|
||||
"events_display.retreating",
|
||||
)}...)</span
|
||||
>`}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderBoats() {
|
||||
return html`
|
||||
${this.outgoingBoats.length > 0
|
||||
? html`
|
||||
<div class="flex flex-wrap gap-y-1 gap-x-2">
|
||||
${this.outgoingBoats.map(
|
||||
(boat) => html`
|
||||
<div class="inline-flex items-center gap-1">
|
||||
${this.renderButton({
|
||||
content: html`${translateText("events_display.boat")}:
|
||||
${renderTroops(boat.troops())}`,
|
||||
onClick: () => this.emitGoToUnitEvent(boat),
|
||||
className: "text-left text-blue-400",
|
||||
translate: false,
|
||||
})}
|
||||
${!boat.retreating()
|
||||
? this.renderButton({
|
||||
content: "❌",
|
||||
onClick: () => this.emitBoatCancelIntent(boat.id()),
|
||||
className: "text-left shrink-0",
|
||||
disabled: boat.retreating(),
|
||||
})
|
||||
: html`<span class="shrink-0 text-blue-400"
|
||||
>(${translateText(
|
||||
"events_display.retreating",
|
||||
)}...)</span
|
||||
>`}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderBetrayalDebuffTimer() {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer || !myPlayer.isTraitor()) {
|
||||
@@ -1018,17 +799,19 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
>
|
||||
${this.renderButton({
|
||||
content: html`
|
||||
Events
|
||||
<span
|
||||
class="${this.newEvents
|
||||
? ""
|
||||
: "hidden"} inline-block px-2 bg-red-500 rounded-lg text-sm"
|
||||
>${this.newEvents}</span
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
${translateText("events_display.events")}
|
||||
${this.newEvents > 0
|
||||
? html`<span
|
||||
class="inline-block px-2 bg-red-500 rounded-lg text-sm"
|
||||
>${this.newEvents}</span
|
||||
>`
|
||||
: ""}
|
||||
</span>
|
||||
`,
|
||||
onClick: this.toggleHidden,
|
||||
className:
|
||||
"text-white cursor-pointer pointer-events-auto w-fit p-2 lg:p-3 rounded-lg bg-gray-800/70 backdrop-blur-sm",
|
||||
"text-white cursor-pointer pointer-events-auto w-fit p-2 lg:p-3 min-[1200px]:rounded-lg max-sm:rounded-tr-lg sm:rounded-tl-lg bg-gray-800/70 backdrop-blur-xs",
|
||||
})}
|
||||
</div>
|
||||
`
|
||||
@@ -1039,9 +822,9 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
>
|
||||
<!-- Button Bar -->
|
||||
<div
|
||||
class="w-full p-2 lg:p-3 bg-gray-800/70 min-[1200px]:rounded-t-lg lg:rounded-tl-lg"
|
||||
class="w-full p-2 lg:p-3 bg-gray-800/70 min-[1200px]:rounded-t-lg sm:rounded-tl-lg"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex justify-between items-center gap-3">
|
||||
<div class="flex gap-4">
|
||||
${this.renderToggleButton(
|
||||
swordIcon,
|
||||
@@ -1087,7 +870,7 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
>
|
||||
<div>
|
||||
<table
|
||||
class="w-full max-h-none border-collapse text-white shadow-lg lg:text-base text-md md:text-xs pointer-events-auto"
|
||||
class="w-full max-h-none border-collapse text-white shadow-lg text-xs lg:text-sm pointer-events-auto"
|
||||
>
|
||||
<tbody>
|
||||
${filteredEvents.map(
|
||||
@@ -1126,7 +909,7 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
${event.buttons.map(
|
||||
(btn) => html`
|
||||
<button
|
||||
class="inline-block px-3 py-1 text-white rounded-sm text-md md:text-sm cursor-pointer transition-colors duration-300
|
||||
class="inline-block px-3 py-1 text-white rounded-sm text-xs lg:text-sm cursor-pointer transition-colors duration-300
|
||||
${btn.className.includes("btn-info")
|
||||
? "bg-blue-500 hover:bg-blue-600"
|
||||
: btn.className.includes(
|
||||
@@ -1161,17 +944,6 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
</tr>
|
||||
`,
|
||||
)}
|
||||
<!--- Incoming attacks row -->
|
||||
${this.incomingAttacks.length > 0
|
||||
? html`
|
||||
<tr class="lg:px-2 lg:py-1 p-1">
|
||||
<td class="lg:px-2 lg:py-1 p-1 text-left">
|
||||
${this.renderIncomingAttacks()}
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
: ""}
|
||||
|
||||
<!--- Betrayal debuff timer row -->
|
||||
${(() => {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
@@ -1190,45 +962,8 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
`
|
||||
: ""}
|
||||
|
||||
<!--- Outgoing attacks row -->
|
||||
${this.outgoingAttacks.length > 0
|
||||
? html`
|
||||
<tr class="lg:px-2 lg:py-1 p-1">
|
||||
<td class="lg:px-2 lg:py-1 p-1 text-left">
|
||||
${this.renderOutgoingAttacks()}
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
: ""}
|
||||
|
||||
<!--- Outgoing land attacks row -->
|
||||
${this.outgoingLandAttacks.length > 0
|
||||
? html`
|
||||
<tr class="lg:px-2 lg:py-1 p-1">
|
||||
<td class="lg:px-2 lg:py-1 p-1 text-left">
|
||||
${this.renderOutgoingLandAttacks()}
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
: ""}
|
||||
|
||||
<!--- Boats row -->
|
||||
${this.outgoingBoats.length > 0
|
||||
? html`
|
||||
<tr class="lg:px-2 lg:py-1 p-1">
|
||||
<td class="lg:px-2 lg:py-1 p-1 text-left">
|
||||
${this.renderBoats()}
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
: ""}
|
||||
|
||||
<!--- Empty row when no events or attacks -->
|
||||
<!--- Empty row when no events -->
|
||||
${filteredEvents.length === 0 &&
|
||||
this.incomingAttacks.length === 0 &&
|
||||
this.outgoingAttacks.length === 0 &&
|
||||
this.outgoingLandAttacks.length === 0 &&
|
||||
this.outgoingBoats.length === 0 &&
|
||||
!(() => {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { Colord } from "colord";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { GameMode } from "../../../core/game/Game";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { GameMode, Team } from "../../../core/game/Game";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { translateText } from "../../Utils";
|
||||
import { Platform } from "../../Platform";
|
||||
import { getTranslatedPlayerTeamLabel, translateText } from "../../Utils";
|
||||
import { ImmunityBarVisibleEvent } from "./ImmunityTimer";
|
||||
import { Layer } from "./Layer";
|
||||
import { SpawnBarVisibleEvent } from "./SpawnTimer";
|
||||
import leaderboardRegularIcon from "/images/LeaderboardIconRegularWhite.svg?url";
|
||||
import leaderboardSolidIcon from "/images/LeaderboardIconSolidWhite.svg?url";
|
||||
import teamRegularIcon from "/images/TeamIconRegularWhite.svg?url";
|
||||
@@ -21,10 +25,15 @@ export class GameLeftSidebar extends LitElement implements Layer {
|
||||
@state()
|
||||
private isPlayerTeamLabelVisible = false;
|
||||
@state()
|
||||
private playerTeam: string | null = null;
|
||||
private playerTeam: Team | null = null;
|
||||
@state()
|
||||
private spawnBarVisible = false;
|
||||
@state()
|
||||
private immunityBarVisible = false;
|
||||
|
||||
private playerColor: Colord = new Colord("#FFFFFF");
|
||||
public game: GameView;
|
||||
public eventBus: EventBus;
|
||||
private _shownOnInit = false;
|
||||
|
||||
createRenderRoot() {
|
||||
@@ -33,11 +42,17 @@ export class GameLeftSidebar extends LitElement implements Layer {
|
||||
|
||||
init() {
|
||||
this.isVisible = true;
|
||||
this.eventBus.on(SpawnBarVisibleEvent, (e) => {
|
||||
this.spawnBarVisible = e.visible;
|
||||
});
|
||||
this.eventBus.on(ImmunityBarVisibleEvent, (e) => {
|
||||
this.immunityBarVisible = e.visible;
|
||||
});
|
||||
if (this.isTeamGame) {
|
||||
this.isPlayerTeamLabelVisible = true;
|
||||
}
|
||||
// Make it visible by default on large screens
|
||||
if (window.innerWidth >= 1024) {
|
||||
if (Platform.isDesktopWidth) {
|
||||
// lg breakpoint
|
||||
this._shownOnInit = true;
|
||||
}
|
||||
@@ -68,6 +83,10 @@ export class GameLeftSidebar extends LitElement implements Layer {
|
||||
}
|
||||
}
|
||||
|
||||
private get barOffset(): number {
|
||||
return (this.spawnBarVisible ? 7 : 0) + (this.immunityBarVisible ? 7 : 0);
|
||||
}
|
||||
|
||||
private toggleLeaderboard(): void {
|
||||
this.isLeaderboardShow = !this.isLeaderboardShow;
|
||||
}
|
||||
@@ -80,19 +99,13 @@ export class GameLeftSidebar extends LitElement implements Layer {
|
||||
return this.game?.config().gameConfig().gameMode === GameMode.Team;
|
||||
}
|
||||
|
||||
private getTranslatedPlayerTeamLabel(): string {
|
||||
if (!this.playerTeam) return "";
|
||||
const translationKey = `team_colors.${this.playerTeam.toLowerCase()}`;
|
||||
const translated = translateText(translationKey);
|
||||
return translated === translationKey ? this.playerTeam : translated;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<aside
|
||||
class=${`fixed top-4 left-4 z-1000 flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-slate-800/40 backdrop-blur-xs shadow-xs rounded-lg transition-transform duration-300 ease-out transform ${
|
||||
class=${`fixed top-0 min-[1200px]:top-4 left-0 min-[1200px]:left-4 z-900 flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-gray-800/70 backdrop-blur-xs shadow-xs min-[1200px]:rounded-lg rounded-br-lg ${this.isLeaderboardShow || this.isTeamLeaderboardShow ? "max-[400px]:w-full max-[400px]:rounded-none" : ""} transition-all duration-300 ease-out transform ${
|
||||
this.isVisible ? "translate-x-0" : "hidden"
|
||||
}`}
|
||||
style="margin-top: ${this.barOffset}px;"
|
||||
>
|
||||
<div class="flex items-center gap-4 xl:gap-6 text-white">
|
||||
<div
|
||||
@@ -152,7 +165,7 @@ export class GameLeftSidebar extends LitElement implements Layer {
|
||||
${this.isPlayerTeamLabelVisible
|
||||
? html`
|
||||
<div
|
||||
class="flex items-center w-full text-white"
|
||||
class="flex items-center w-full text-white mt-2"
|
||||
@contextmenu=${(e: Event) => e.preventDefault()}
|
||||
>
|
||||
${translateText("help_modal.ui_your_team")}
|
||||
@@ -160,13 +173,14 @@ export class GameLeftSidebar extends LitElement implements Layer {
|
||||
style="--color: ${this.playerColor.toRgbString()}"
|
||||
class="text-(--color)"
|
||||
>
|
||||
${this.getTranslatedPlayerTeamLabel()} ⦿
|
||||
${getTranslatedPlayerTeamLabel(this.playerTeam)}
|
||||
⦿
|
||||
</span>
|
||||
</div>
|
||||
`
|
||||
: null}
|
||||
<div
|
||||
class=${`block lg:flex flex-wrap ${this.isLeaderboardShow && this.isTeamLeaderboardShow ? "gap-2" : ""}`}
|
||||
class=${`block lg:flex flex-wrap overflow-x-auto min-w-0 w-full ${this.isLeaderboardShow && this.isTeamLeaderboardShow ? "gap-2" : ""}`}
|
||||
>
|
||||
<leader-board .visible=${this.isLeaderboardShow}></leader-board>
|
||||
<team-stats
|
||||
|
||||
@@ -6,9 +6,11 @@ import { GameView } from "../../../core/game/GameView";
|
||||
import { crazyGamesSDK } from "../../CrazyGamesSDK";
|
||||
import { PauseGameIntentEvent, SendWinnerEvent } from "../../Transport";
|
||||
import { translateText } from "../../Utils";
|
||||
import { ImmunityBarVisibleEvent } from "./ImmunityTimer";
|
||||
import { Layer } from "./Layer";
|
||||
import { ShowReplayPanelEvent } from "./ReplayPanel";
|
||||
import { ShowSettingsModalEvent } from "./SettingsModal";
|
||||
import { SpawnBarVisibleEvent } from "./SpawnTimer";
|
||||
import exitIcon from "/images/ExitIconWhite.svg?url";
|
||||
import FastForwardIconSolid from "/images/FastForwardIconSolidWhite.svg?url";
|
||||
import pauseIcon from "/images/PauseIconWhite.svg?url";
|
||||
@@ -37,6 +39,8 @@ export class GameRightSidebar extends LitElement implements Layer {
|
||||
|
||||
private hasWinner = false;
|
||||
private isLobbyCreator = false;
|
||||
private spawnBarVisible = false;
|
||||
private immunityBarVisible = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
@@ -49,6 +53,15 @@ export class GameRightSidebar extends LitElement implements Layer {
|
||||
this._isVisible = true;
|
||||
this.game.inSpawnPhase();
|
||||
|
||||
this.eventBus.on(SpawnBarVisibleEvent, (e) => {
|
||||
this.spawnBarVisible = e.visible;
|
||||
this.updateParentOffset();
|
||||
});
|
||||
this.eventBus.on(ImmunityBarVisibleEvent, (e) => {
|
||||
this.immunityBarVisible = e.visible;
|
||||
this.updateParentOffset();
|
||||
});
|
||||
|
||||
this.eventBus.on(SendWinnerEvent, () => {
|
||||
this.hasWinner = true;
|
||||
this.requestUpdate();
|
||||
@@ -91,6 +104,15 @@ export class GameRightSidebar extends LitElement implements Layer {
|
||||
}
|
||||
}
|
||||
|
||||
private updateParentOffset(): void {
|
||||
const offset =
|
||||
(this.spawnBarVisible ? 7 : 0) + (this.immunityBarVisible ? 7 : 0);
|
||||
const parent = this.parentElement as HTMLElement;
|
||||
if (parent) {
|
||||
parent.style.marginTop = `${offset}px`;
|
||||
}
|
||||
}
|
||||
|
||||
private secondsToHms = (d: number): string => {
|
||||
const pad = (n: number) => (n < 10 ? `0${n}` : n);
|
||||
|
||||
@@ -153,7 +175,7 @@ export class GameRightSidebar extends LitElement implements Layer {
|
||||
|
||||
return html`
|
||||
<aside
|
||||
class=${`w-fit flex flex-row items-center gap-3 py-2 px-3 bg-gray-800/70 backdrop-blur-xs shadow-xs rounded-lg transition-transform duration-300 ease-out transform text-white ${
|
||||
class=${`w-fit flex flex-row items-center gap-3 py-2 px-3 bg-gray-800/70 backdrop-blur-xs shadow-xs min-[1200px]:rounded-lg rounded-bl-lg transition-transform duration-300 ease-out transform text-white ${
|
||||
this._isVisible ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
@contextmenu=${(e: Event) => e.preventDefault()}
|
||||
|
||||
@@ -16,6 +16,15 @@ export class HeadsUpMessage extends LitElement implements Layer {
|
||||
@state()
|
||||
private isPaused = false;
|
||||
|
||||
@state()
|
||||
private isImmunityActive = false;
|
||||
|
||||
@state()
|
||||
private isCatchingUp = false;
|
||||
private catchingUpTicks = 0;
|
||||
|
||||
private static readonly CATCHING_UP_SHOW_THRESHOLD = 10;
|
||||
|
||||
@state()
|
||||
private toastMessage: string | import("lit").TemplateResult | null = null;
|
||||
@state()
|
||||
@@ -79,11 +88,40 @@ export class HeadsUpMessage extends LitElement implements Layer {
|
||||
this.isPaused = pauseUpdate.paused;
|
||||
}
|
||||
|
||||
this.isVisible = this.game.inSpawnPhase() || this.isPaused;
|
||||
const showImmunityHudDuration = 10 * 10;
|
||||
const spawnEnd = this.game.config().numSpawnPhaseTurns();
|
||||
const ticksSinceSpawnEnd = this.game.ticks() - spawnEnd;
|
||||
|
||||
this.isImmunityActive =
|
||||
this.game.config().hasExtendedSpawnImmunity() &&
|
||||
!this.game.inSpawnPhase() &&
|
||||
this.game.isSpawnImmunityActive() &&
|
||||
ticksSinceSpawnEnd < showImmunityHudDuration;
|
||||
|
||||
const currentlyCatchingUp =
|
||||
!this.game.config().isReplay() && this.game.isCatchingUp();
|
||||
|
||||
if (currentlyCatchingUp) {
|
||||
this.catchingUpTicks++;
|
||||
} else {
|
||||
this.catchingUpTicks = 0;
|
||||
}
|
||||
|
||||
this.isCatchingUp =
|
||||
this.catchingUpTicks >= HeadsUpMessage.CATCHING_UP_SHOW_THRESHOLD;
|
||||
|
||||
this.isVisible =
|
||||
this.game.inSpawnPhase() ||
|
||||
this.isPaused ||
|
||||
this.isImmunityActive ||
|
||||
this.isCatchingUp;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private getMessage(): string {
|
||||
if (this.isCatchingUp) {
|
||||
return translateText("heads_up_message.catching_up");
|
||||
}
|
||||
if (this.isPaused) {
|
||||
if (this.game.config().gameConfig().gameType === GameType.Singleplayer) {
|
||||
return translateText("heads_up_message.singleplayer_game_paused");
|
||||
@@ -91,6 +129,11 @@ export class HeadsUpMessage extends LitElement implements Layer {
|
||||
return translateText("heads_up_message.multiplayer_game_paused");
|
||||
}
|
||||
}
|
||||
if (this.isImmunityActive) {
|
||||
return translateText("heads_up_message.pvp_immunity_active", {
|
||||
seconds: Math.round(this.game.config().spawnImmunityDuration() / 10),
|
||||
});
|
||||
}
|
||||
return this.game.config().isRandomSpawn()
|
||||
? translateText("heads_up_message.random_spawn")
|
||||
: translateText("heads_up_message.choose_spawn");
|
||||
@@ -102,7 +145,7 @@ export class HeadsUpMessage extends LitElement implements Layer {
|
||||
${this.toastMessage
|
||||
? html`
|
||||
<div
|
||||
class="fixed top-6 left-1/2 -translate-x-1/2 z-[11001] px-6 py-4 rounded-xl transition-all duration-300 animate-fade-in-out"
|
||||
class="fixed top-6 left-1/2 -translate-x-1/2 z-[800] px-6 py-4 rounded-xl transition-all duration-300 animate-fade-in-out"
|
||||
style="max-width: 90vw; min-width: 200px; text-align: center;
|
||||
background: ${this.toastColor === "red"
|
||||
? "rgba(239,68,68,0.1)"
|
||||
@@ -126,11 +169,11 @@ export class HeadsUpMessage extends LitElement implements Layer {
|
||||
${this.isVisible
|
||||
? html`
|
||||
<div
|
||||
class="fixed top-[15%] left-1/2 -translate-x-1/2 z-[11000]
|
||||
inline-flex items-center justify-center h-8 lg:h-10
|
||||
class="fixed top-[15%] left-1/2 -translate-x-1/2 z-[799]
|
||||
inline-flex items-center justify-center min-h-8 lg:min-h-10
|
||||
w-fit max-w-[90vw]
|
||||
bg-gray-900/60 rounded-md lg:rounded-lg
|
||||
backdrop-blur-md text-white text-md lg:text-xl px-3 lg:px-4
|
||||
bg-gray-800/70 rounded-md lg:rounded-lg
|
||||
backdrop-blur-xs text-white text-md lg:text-xl px-3 lg:px-4 py-1
|
||||
text-center break-words"
|
||||
style="word-wrap: break-word; hyphens: auto;"
|
||||
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { EventBus, GameEvent } from "../../../core/EventBus";
|
||||
import { GameMode } from "../../../core/game/Game";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
export class ImmunityBarVisibleEvent implements GameEvent {
|
||||
constructor(public readonly visible: boolean) {}
|
||||
}
|
||||
|
||||
@customElement("immunity-timer")
|
||||
export class ImmunityTimer extends LitElement implements Layer {
|
||||
public game: GameView;
|
||||
public eventBus: EventBus;
|
||||
|
||||
private isVisible = false;
|
||||
private _barVisible = false;
|
||||
private isActive = false;
|
||||
private progressRatio = 0;
|
||||
|
||||
@@ -41,26 +48,29 @@ export class ImmunityTimer extends LitElement implements Layer {
|
||||
const immunityDuration = this.game.config().spawnImmunityDuration();
|
||||
const spawnPhaseTurns = this.game.config().numSpawnPhaseTurns();
|
||||
|
||||
if (immunityDuration <= 5 * 10 || this.game.inSpawnPhase()) {
|
||||
if (
|
||||
!this.game.config().hasExtendedSpawnImmunity() ||
|
||||
this.game.inSpawnPhase()
|
||||
) {
|
||||
this.setInactive();
|
||||
return;
|
||||
} else {
|
||||
const immunityEnd = spawnPhaseTurns + immunityDuration;
|
||||
const ticks = this.game.ticks();
|
||||
|
||||
if (ticks >= immunityEnd || ticks < spawnPhaseTurns) {
|
||||
this.setInactive();
|
||||
} else {
|
||||
const elapsedTicks = Math.max(0, ticks - spawnPhaseTurns);
|
||||
this.progressRatio = Math.min(
|
||||
1,
|
||||
Math.max(0, elapsedTicks / immunityDuration),
|
||||
);
|
||||
this.isActive = true;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
const immunityEnd = spawnPhaseTurns + immunityDuration;
|
||||
const ticks = this.game.ticks();
|
||||
|
||||
if (ticks >= immunityEnd || ticks < spawnPhaseTurns) {
|
||||
this.setInactive();
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsedTicks = Math.max(0, ticks - spawnPhaseTurns);
|
||||
this.progressRatio = Math.min(
|
||||
1,
|
||||
Math.max(0, elapsedTicks / immunityDuration),
|
||||
);
|
||||
this.isActive = true;
|
||||
this.requestUpdate();
|
||||
this.emitBarVisibility();
|
||||
}
|
||||
|
||||
private setInactive() {
|
||||
@@ -70,6 +80,14 @@ export class ImmunityTimer extends LitElement implements Layer {
|
||||
}
|
||||
}
|
||||
|
||||
private emitBarVisibility() {
|
||||
const nowVisible = this.isVisible && this.isActive;
|
||||
if (nowVisible !== this._barVisible) {
|
||||
this._barVisible = nowVisible;
|
||||
this.eventBus?.emit(new ImmunityBarVisibleEvent(this._barVisible));
|
||||
}
|
||||
}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,12 @@ export class Leaderboard extends LitElement implements Layer {
|
||||
|
||||
init() {}
|
||||
|
||||
willUpdate(changed: Map<string, unknown>) {
|
||||
if (changed.has("visible") && this.visible) {
|
||||
this.updateLeaderboard();
|
||||
}
|
||||
}
|
||||
|
||||
getTickIntervalMs() {
|
||||
return 1000;
|
||||
}
|
||||
@@ -184,10 +190,10 @@ export class Leaderboard extends LitElement implements Layer {
|
||||
@contextmenu=${(e: Event) => e.preventDefault()}
|
||||
>
|
||||
<div
|
||||
class="grid bg-gray-800/70 w-full text-xs md:text-xs lg:text-sm"
|
||||
style="grid-template-columns: 30px 100px 70px 55px 105px;"
|
||||
class="grid bg-gray-800/85 w-full text-xs md:text-xs lg:text-sm rounded-lg overflow-hidden"
|
||||
style="grid-template-columns: minmax(24px, 30px) minmax(60px, 100px) minmax(45px, 70px) minmax(40px, 55px) minmax(55px, 105px);"
|
||||
>
|
||||
<div class="contents font-bold bg-gray-700/50">
|
||||
<div class="contents font-bold bg-gray-700/60">
|
||||
<div class="py-1 md:py-2 text-center border-b border-slate-500">
|
||||
#
|
||||
</div>
|
||||
@@ -234,28 +240,51 @@ export class Leaderboard extends LitElement implements Layer {
|
||||
${repeat(
|
||||
this.players,
|
||||
(p) => p.player.id(),
|
||||
(player) => html`
|
||||
(player, index) => html`
|
||||
<div
|
||||
class="contents hover:bg-slate-600/60 ${player.isOnSameTeam
|
||||
? "font-bold"
|
||||
: ""} cursor-pointer"
|
||||
@click=${() => this.handleRowClickPlayer(player.player)}
|
||||
>
|
||||
<div class="py-1 md:py-2 text-center border-b border-slate-500">
|
||||
<div
|
||||
class="py-1 md:py-2 text-center ${index <
|
||||
this.players.length - 1
|
||||
? "border-b border-slate-500"
|
||||
: ""}"
|
||||
>
|
||||
${player.position}
|
||||
</div>
|
||||
<div
|
||||
class="py-1 md:py-2 text-center border-b border-slate-500 truncate"
|
||||
class="py-1 md:py-2 text-center ${index <
|
||||
this.players.length - 1
|
||||
? "border-b border-slate-500"
|
||||
: ""} truncate"
|
||||
>
|
||||
${player.name}
|
||||
</div>
|
||||
<div class="py-1 md:py-2 text-center border-b border-slate-500">
|
||||
<div
|
||||
class="py-1 md:py-2 text-center ${index <
|
||||
this.players.length - 1
|
||||
? "border-b border-slate-500"
|
||||
: ""}"
|
||||
>
|
||||
${player.score}
|
||||
</div>
|
||||
<div class="py-1 md:py-2 text-center border-b border-slate-500">
|
||||
<div
|
||||
class="py-1 md:py-2 text-center ${index <
|
||||
this.players.length - 1
|
||||
? "border-b border-slate-500"
|
||||
: ""}"
|
||||
>
|
||||
${player.gold}
|
||||
</div>
|
||||
<div class="py-1 md:py-2 text-center border-b border-slate-500">
|
||||
<div
|
||||
class="py-1 md:py-2 text-center ${index <
|
||||
this.players.length - 1
|
||||
? "border-b border-slate-500"
|
||||
: ""}"
|
||||
>
|
||||
${player.maxTroops}
|
||||
</div>
|
||||
</div>
|
||||
@@ -265,9 +294,9 @@ export class Leaderboard extends LitElement implements Layer {
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="mt-1 px-1.5 pb-0.5 md:px-2 text-xs md:text-xs lg:text-sm
|
||||
class="mt-2 p-0.5 px-1.5 md:px-2 text-xs md:text-xs lg:text-sm
|
||||
border rounded-md border-slate-500 transition-colors
|
||||
text-white mx-auto block hover:bg-white/10"
|
||||
text-white mx-auto block hover:bg-white/10 bg-gray-700/50"
|
||||
@click=${() => {
|
||||
this.showTopFive = !this.showTopFive;
|
||||
this.updateLeaderboard();
|
||||
|
||||
@@ -112,7 +112,7 @@ export class MainRadialMenu extends LitElement implements Layer {
|
||||
screenX: number | null = null,
|
||||
screenY: number | null = null,
|
||||
) {
|
||||
this.buildMenu.playerActions = actions;
|
||||
this.buildMenu.playerBuildables = actions.buildableUnits;
|
||||
|
||||
const tileOwner = this.game.owner(tile);
|
||||
const recipient = tileOwner.isPlayer() ? (tileOwner as PlayerView) : null;
|
||||
|
||||
@@ -26,7 +26,7 @@ export class NukeTrajectoryPreviewLayer implements Layer {
|
||||
private lastTrajectoryUpdate: number = 0;
|
||||
private lastTargetTile: TileRef | null = null;
|
||||
private currentGhostStructure: UnitType | null = null;
|
||||
// Cache spawn tile to avoid expensive player.actions() calls
|
||||
// Cache spawn tile to avoid expensive player.buildables() calls
|
||||
private cachedSpawnTile: TileRef | null = null;
|
||||
|
||||
constructor(
|
||||
@@ -75,7 +75,7 @@ export class NukeTrajectoryPreviewLayer implements Layer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update trajectory preview - called from tick() to cache spawn tile via expensive player.actions() call
|
||||
* Update trajectory preview - called from tick() to cache spawn tile via expensive player.buildables() call
|
||||
* This only runs when target tile changes, minimizing worker thread communication
|
||||
*/
|
||||
private updateTrajectoryPreview() {
|
||||
@@ -138,14 +138,14 @@ export class NukeTrajectoryPreviewLayer implements Layer {
|
||||
|
||||
// Get buildable units to find spawn tile (expensive call - only on tick when tile changes)
|
||||
player
|
||||
.actions(targetTile)
|
||||
.then((actions) => {
|
||||
.buildables(targetTile, [ghostStructure])
|
||||
.then((buildables) => {
|
||||
// Ignore stale results if target changed
|
||||
if (this.lastTargetTile !== targetTile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const buildableUnit = actions.buildableUnits.find(
|
||||
const buildableUnit = buildables.find(
|
||||
(bu) => bu.type === ghostStructure,
|
||||
);
|
||||
|
||||
@@ -171,7 +171,7 @@ export class NukeTrajectoryPreviewLayer implements Layer {
|
||||
|
||||
/**
|
||||
* Update trajectory path - called from renderLayer() each frame for smooth visual feedback
|
||||
* Uses cached spawn tile to avoid expensive player.actions() calls
|
||||
* Uses cached spawn tile to avoid expensive player.buildables() calls
|
||||
*/
|
||||
private updateTrajectoryPath() {
|
||||
const ghostStructure = this.currentGhostStructure;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { PlayerActions } from "../../../core/game/Game";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import { PlayerView } from "../../../core/game/GameView";
|
||||
import {
|
||||
SendAllianceExtensionIntentEvent,
|
||||
SendAllianceRequestIntentEvent,
|
||||
SendAttackIntentEvent,
|
||||
SendBoatAttackIntentEvent,
|
||||
@@ -23,13 +23,6 @@ export class PlayerActionHandler {
|
||||
private uiState: UIState,
|
||||
) {}
|
||||
|
||||
async getPlayerActions(
|
||||
player: PlayerView,
|
||||
tile: TileRef,
|
||||
): Promise<PlayerActions> {
|
||||
return await player.actions(tile);
|
||||
}
|
||||
|
||||
handleAttack(player: PlayerView, targetId: string | null) {
|
||||
this.eventBus.emit(
|
||||
new SendAttackIntentEvent(
|
||||
@@ -63,6 +56,10 @@ export class PlayerActionHandler {
|
||||
this.eventBus.emit(new SendAllianceRequestIntentEvent(player, recipient));
|
||||
}
|
||||
|
||||
handleExtendAlliance(recipient: PlayerView) {
|
||||
this.eventBus.emit(new SendAllianceExtensionIntentEvent(recipient));
|
||||
}
|
||||
|
||||
handleBreakAlliance(player: PlayerView, recipient: PlayerView) {
|
||||
this.eventBus.emit(new SendBreakAllianceIntentEvent(player, recipient));
|
||||
}
|
||||
|
||||
@@ -13,8 +13,13 @@ import {
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import { AllianceView } from "../../../core/game/GameUpdates";
|
||||
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
|
||||
import { ContextMenuEvent, MouseMoveEvent } from "../../InputHandler";
|
||||
import {
|
||||
ContextMenuEvent,
|
||||
MouseMoveEvent,
|
||||
TouchEvent,
|
||||
} from "../../InputHandler";
|
||||
import {
|
||||
getTranslatedPlayerTeamLabel,
|
||||
renderDuration,
|
||||
renderNumber,
|
||||
renderTroops,
|
||||
@@ -22,8 +27,10 @@ import {
|
||||
} from "../../Utils";
|
||||
import { getFirstPlacePlayer, getPlayerIcons } from "../PlayerIcons";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { ImmunityBarVisibleEvent } from "./ImmunityTimer";
|
||||
import { Layer } from "./Layer";
|
||||
import { CloseRadialMenuEvent } from "./RadialMenu";
|
||||
import { SpawnBarVisibleEvent } from "./SpawnTimer";
|
||||
import allianceIcon from "/images/AllianceIcon.svg?url";
|
||||
import warshipIcon from "/images/BattleshipIconWhite.svg?url";
|
||||
import cityIcon from "/images/CityIconWhite.svg?url";
|
||||
@@ -77,8 +84,17 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
@state()
|
||||
private _isInfoVisible: boolean = false;
|
||||
|
||||
@state()
|
||||
private spawnBarVisible = false;
|
||||
@state()
|
||||
private immunityBarVisible = false;
|
||||
|
||||
private _isActive = false;
|
||||
|
||||
private get barOffset(): number {
|
||||
return (this.spawnBarVisible ? 7 : 0) + (this.immunityBarVisible ? 7 : 0);
|
||||
}
|
||||
|
||||
private lastMouseUpdate = 0;
|
||||
|
||||
init() {
|
||||
@@ -88,7 +104,14 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
this.eventBus.on(ContextMenuEvent, (e: ContextMenuEvent) =>
|
||||
this.maybeShow(e.x, e.y),
|
||||
);
|
||||
this.eventBus.on(TouchEvent, (e: TouchEvent) => this.maybeShow(e.x, e.y));
|
||||
this.eventBus.on(CloseRadialMenuEvent, () => this.hide());
|
||||
this.eventBus.on(SpawnBarVisibleEvent, (e) => {
|
||||
this.spawnBarVisible = e.visible;
|
||||
});
|
||||
this.eventBus.on(ImmunityBarVisibleEvent, (e) => {
|
||||
this.immunityBarVisible = e.visible;
|
||||
});
|
||||
this._isActive = true;
|
||||
}
|
||||
|
||||
@@ -155,6 +178,24 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private getPlayerNameColor(
|
||||
player: PlayerView,
|
||||
myPlayer: PlayerView | null | undefined,
|
||||
isFriendly: boolean,
|
||||
): string {
|
||||
if (isFriendly) return "text-green-500";
|
||||
if (
|
||||
myPlayer &&
|
||||
myPlayer !== player &&
|
||||
player.type() === PlayerType.Nation
|
||||
) {
|
||||
const relation =
|
||||
this.playerProfile?.relations[myPlayer.smallID()] ?? Relation.Neutral;
|
||||
return this.getRelationClass(relation);
|
||||
}
|
||||
return "text-white";
|
||||
}
|
||||
|
||||
private getRelationClass(relation: Relation): string {
|
||||
switch (relation) {
|
||||
case Relation.Hostile:
|
||||
@@ -255,7 +296,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
.find((alliance) => alliance.other === player.id());
|
||||
if (alliance !== undefined) {
|
||||
allianceHtml = html` <div
|
||||
class="flex flex-col items-center ml-auto mr-0 text-sm font-bold leading-tight"
|
||||
class="flex items-center ml-auto mr-0 gap-1 text-sm font-bold leading-tight"
|
||||
>
|
||||
<img src=${allianceIcon} width="20" height="20" />
|
||||
${this.allianceExpirationText(alliance)}
|
||||
@@ -274,6 +315,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
playerType = translateText("player_type.player");
|
||||
break;
|
||||
}
|
||||
const playerTeam = getTranslatedPlayerTeamLabel(player.team());
|
||||
|
||||
return html`
|
||||
<div class="flex items-start gap-2 lg:gap-3 p-1.5 lg:p-2">
|
||||
@@ -293,9 +335,11 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
<!-- Right: Player identity + Units below -->
|
||||
<div class="flex flex-col justify-between self-stretch">
|
||||
<div
|
||||
class="flex items-center gap-2 font-bold text-sm lg:text-lg ${isFriendly
|
||||
? "text-green-500"
|
||||
: "text-white"}"
|
||||
class="flex items-center gap-2 font-bold text-sm lg:text-lg ${this.getPlayerNameColor(
|
||||
player,
|
||||
myPlayer,
|
||||
isFriendly ?? false,
|
||||
)}"
|
||||
>
|
||||
${player.cosmetics.flag
|
||||
? player.cosmetics.flag!.startsWith("!")
|
||||
@@ -315,7 +359,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
/>`
|
||||
: html``}
|
||||
<span>${player.name()}</span>
|
||||
${player.team() !== null && player.type() !== PlayerType.Bot
|
||||
${playerTeam !== "" && player.type() !== PlayerType.Bot
|
||||
? html`<div class="flex flex-col leading-tight">
|
||||
<span class="text-gray-400 text-xs font-normal"
|
||||
>${playerType}</span
|
||||
@@ -327,7 +371,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
.theme()
|
||||
.teamColor(player.team()!)
|
||||
.toHex()}"
|
||||
>${player.team()}</span
|
||||
>${playerTeam}</span
|
||||
>]</span
|
||||
>
|
||||
</div>`
|
||||
@@ -452,11 +496,13 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="fixed top-0 lg:top-4 left-0 right-0 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 z-[1001]"
|
||||
class="fixed top-0 min-[1200px]:top-4 left-0 right-0 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 z-[1001]"
|
||||
style="margin-top: ${this.barOffset}px;"
|
||||
@click=${() => this.hide()}
|
||||
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
|
||||
>
|
||||
<div
|
||||
class="bg-gray-800/70 backdrop-blur-xs shadow-xs lg:rounded-lg shadow-lg transition-all duration-300 text-white text-lg lg:text-base w-full sm:w-auto sm:min-w-[400px] overflow-hidden ${containerClasses}"
|
||||
class="bg-gray-800/70 backdrop-blur-xs shadow-xs min-[1200px]:rounded-lg sm:rounded-b-lg shadow-lg text-white text-lg lg:text-base w-full sm:w-auto sm:min-w-[400px] overflow-hidden ${containerClasses}"
|
||||
>
|
||||
${this.player !== null ? this.renderPlayerInfo(this.player) : ""}
|
||||
${this.unit !== null ? this.renderUnitInfo(this.unit) : ""}
|
||||
|
||||
@@ -121,9 +121,9 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
// Refresh actions & alliance expiry
|
||||
const myPlayer = this.g.myPlayer();
|
||||
if (myPlayer !== null && myPlayer.isAlive()) {
|
||||
this.actions = await myPlayer.actions(this.tile);
|
||||
if (this.actions?.interaction?.allianceExpiresAt !== undefined) {
|
||||
const expiresAt = this.actions.interaction.allianceExpiresAt;
|
||||
this.actions = await myPlayer.actions(this.tile, null);
|
||||
if (this.actions?.interaction?.allianceInfo?.expiresAt !== undefined) {
|
||||
const expiresAt = this.actions.interaction.allianceInfo.expiresAt;
|
||||
const remainingTicks = expiresAt - this.g.ticks();
|
||||
const remainingSeconds = Math.max(0, Math.floor(remainingTicks / 10)); // 10 ticks per second
|
||||
|
||||
@@ -340,22 +340,19 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
case PlayerType.Nation:
|
||||
return {
|
||||
labelKey: "player_type.nation",
|
||||
aria: "Nation player",
|
||||
classes: "border-indigo-400/25 bg-indigo-500/10 text-indigo-200",
|
||||
icon: "🏛️",
|
||||
};
|
||||
case PlayerType.Bot:
|
||||
return {
|
||||
labelKey: "player_type.bot",
|
||||
aria: "Bot",
|
||||
classes: "border-purple-400/25 bg-purple-500/10 text-purple-200",
|
||||
icon: "🤖",
|
||||
icon: "⚔️",
|
||||
};
|
||||
case PlayerType.Human:
|
||||
default:
|
||||
return {
|
||||
labelKey: "player_type.player",
|
||||
aria: "Human player",
|
||||
classes: "border-zinc-400/20 bg-zinc-500/5 text-zinc-300",
|
||||
icon: "👤",
|
||||
};
|
||||
@@ -517,7 +514,7 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
? html`<span
|
||||
class=${`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-semibold ${chip.classes}`}
|
||||
role="status"
|
||||
aria-label=${chip.aria}
|
||||
aria-label=${translateText(chip.labelKey)}
|
||||
title=${translateText(chip.labelKey)}
|
||||
>
|
||||
<span aria-hidden="true" class="leading-none">${chip.icon}</span>
|
||||
|
||||
@@ -11,6 +11,16 @@ import {
|
||||
} from "./RadialMenuElements";
|
||||
import backIcon from "/images/BackIconWhite.svg?url";
|
||||
|
||||
function resolveColor(
|
||||
item: MenuElement,
|
||||
params: MenuElementParams | null,
|
||||
): string | undefined {
|
||||
if (typeof item.color === "function") {
|
||||
return params ? item.color(params) : undefined;
|
||||
}
|
||||
return item.color;
|
||||
}
|
||||
|
||||
export class CloseRadialMenuEvent implements GameEvent {
|
||||
constructor() {}
|
||||
}
|
||||
@@ -322,7 +332,7 @@ export class RadialMenu implements Layer {
|
||||
const disabled = this.params === null || d.data.disabled(this.params);
|
||||
const color = disabled
|
||||
? this.config.disabledColor
|
||||
: (d.data.color ?? "#333333");
|
||||
: (resolveColor(d.data, this.params) ?? "#333333");
|
||||
const opacity = disabled ? 0.5 : 0.7;
|
||||
|
||||
if (d.data.id === this.selectedItemId && this.currentLevel > level) {
|
||||
@@ -349,6 +359,55 @@ export class RadialMenu implements Layer {
|
||||
)
|
||||
.attr("data-id", (d) => d.data.id);
|
||||
|
||||
// Timer gradient for items with timerFraction
|
||||
arcs.each((d) => {
|
||||
if (d.data.timerFraction && this.params) {
|
||||
const fraction = d.data.timerFraction(this.params);
|
||||
const disabled = this.params === null || d.data.disabled(this.params);
|
||||
const baseColor = disabled
|
||||
? this.config.disabledColor
|
||||
: (resolveColor(d.data, this.params) ?? "#333333");
|
||||
const opacity = disabled ? 0.5 : 0.7;
|
||||
|
||||
const normalColor =
|
||||
d3.color(baseColor)?.copy({ opacity: opacity })?.toString() ??
|
||||
baseColor;
|
||||
const interpolated = d3.color(
|
||||
d3.interpolateRgb(baseColor, "white")(0.4),
|
||||
);
|
||||
const fadedColor =
|
||||
interpolated?.copy({ opacity })?.toString() ?? normalColor;
|
||||
|
||||
const gradientId = `timer-gradient-${d.data.id}`;
|
||||
const defs = this.menuElement.select("defs");
|
||||
defs.select(`#${gradientId}`).remove();
|
||||
|
||||
const offset = 1 - fraction;
|
||||
const gradient = defs
|
||||
.append("linearGradient")
|
||||
.attr("id", gradientId)
|
||||
.attr("x1", 0)
|
||||
.attr("y1", 0)
|
||||
.attr("x2", 0)
|
||||
.attr("y2", 1);
|
||||
|
||||
gradient
|
||||
.append("stop")
|
||||
.attr("class", "timer-stop-faded")
|
||||
.attr("offset", offset)
|
||||
.attr("stop-color", fadedColor);
|
||||
|
||||
gradient
|
||||
.append("stop")
|
||||
.attr("class", "timer-stop-normal")
|
||||
.attr("offset", offset)
|
||||
.attr("stop-color", normalColor);
|
||||
|
||||
const path = d3.select(`path[data-id="${d.data.id}"]`);
|
||||
path.attr("fill", `url(#${gradientId})`);
|
||||
}
|
||||
});
|
||||
|
||||
arcs.each((d) => {
|
||||
const pathId = d.data.id;
|
||||
const path = d3.select(`path[data-id="${pathId}"]`);
|
||||
@@ -365,7 +424,7 @@ export class RadialMenu implements Layer {
|
||||
const color =
|
||||
this.params === null || d.data.disabled(this.params)
|
||||
? this.config.disabledColor
|
||||
: (d.data.color ?? "#333333");
|
||||
: (resolveColor(d.data, this.params) ?? "#333333");
|
||||
path.attr("fill", color);
|
||||
}
|
||||
});
|
||||
@@ -431,12 +490,17 @@ export class RadialMenu implements Layer {
|
||||
path.attr("stroke-width", "2");
|
||||
const color = disabled
|
||||
? this.config.disabledColor
|
||||
: (d.data.color ?? "#333333");
|
||||
: (resolveColor(d.data, this.params) ?? "#333333");
|
||||
const opacity = disabled ? 0.5 : 0.7;
|
||||
path.attr(
|
||||
"fill",
|
||||
d3.color(color)?.copy({ opacity: opacity })?.toString() ?? color,
|
||||
);
|
||||
|
||||
if (d.data.timerFraction) {
|
||||
path.attr("fill", `url(#timer-gradient-${d.data.id})`);
|
||||
} else {
|
||||
path.attr(
|
||||
"fill",
|
||||
d3.color(color)?.copy({ opacity: opacity })?.toString() ?? color,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onClick = (d: d3.PieArcDatum<MenuElement>, event: Event) => {
|
||||
@@ -529,12 +593,34 @@ export class RadialMenu implements Layer {
|
||||
.attr("class", "menu-item-content")
|
||||
.style("pointer-events", "none")
|
||||
.attr("data-id", (d) => d.data.id)
|
||||
.attr("data-cx", (d) => arc.centroid(d)[0].toString())
|
||||
.attr("data-cy", (d) => arc.centroid(d)[1].toString())
|
||||
.each((d) => {
|
||||
const contentId = d.data.id;
|
||||
const content = d3.select(`g[data-id="${contentId}"]`);
|
||||
const disabled = this.isItemDisabled(d.data);
|
||||
|
||||
if (d.data.text) {
|
||||
if (d.data.renderType && this.params) {
|
||||
const stateKey = this.getStateKeyByType(
|
||||
d.data.renderType,
|
||||
disabled,
|
||||
this.params,
|
||||
);
|
||||
if (stateKey) {
|
||||
content.attr("data-prev-state", stateKey);
|
||||
}
|
||||
if (d.data.renderType === "allyExtend") {
|
||||
this.renderAllyExtendIcon(
|
||||
content.node()! as SVGGElement,
|
||||
arc.centroid(d)[0],
|
||||
arc.centroid(d)[1],
|
||||
this.config.iconSize,
|
||||
disabled,
|
||||
this.params,
|
||||
d.data.icon,
|
||||
);
|
||||
}
|
||||
} else if (d.data.text) {
|
||||
content
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
@@ -848,10 +934,7 @@ export class RadialMenu implements Layer {
|
||||
|
||||
public disableAllButtons() {
|
||||
this.updateCenterButtonState("default");
|
||||
|
||||
for (const item of this.currentMenuItems) {
|
||||
item.color = this.config.disabledColor;
|
||||
}
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
public updateCenterButtonState(state: CenterButtonState) {
|
||||
@@ -1043,42 +1126,51 @@ export class RadialMenu implements Layer {
|
||||
const disabled = this.isItemDisabled(item);
|
||||
const color = disabled
|
||||
? this.config.disabledColor
|
||||
: (item.color ?? "#333333");
|
||||
: (resolveColor(item, this.params) ?? "#333333");
|
||||
const opacity = disabled ? 0.5 : 0.7;
|
||||
|
||||
// Update path appearance
|
||||
path.attr(
|
||||
"fill",
|
||||
d3.color(color)?.copy({ opacity: opacity })?.toString() ?? color,
|
||||
);
|
||||
// Update path appearance (skip fill for timer items — gradient handles it)
|
||||
if (!item.timerFraction) {
|
||||
path.attr(
|
||||
"fill",
|
||||
d3.color(color)?.copy({ opacity: opacity })?.toString() ?? color,
|
||||
);
|
||||
}
|
||||
path.style("opacity", disabled ? 0.5 : 1);
|
||||
path.style("cursor", disabled ? "not-allowed" : "pointer");
|
||||
|
||||
// Update icon/text appearance using the same logic as renderIconsAndText
|
||||
const icon = this.menuIcons.get(itemId);
|
||||
if (icon) {
|
||||
// Update text opacity
|
||||
const textElement = icon.select("text");
|
||||
if (!textElement.empty()) {
|
||||
textElement.style("opacity", disabled ? 0.5 : 1);
|
||||
}
|
||||
if (item.renderType === "allyExtend" && this.params) {
|
||||
this.refreshAllyExtendIcon(item, disabled, icon);
|
||||
} else {
|
||||
// Update text opacity
|
||||
const textElement = icon.select("text");
|
||||
if (!textElement.empty()) {
|
||||
textElement.style("opacity", disabled ? 0.5 : 1);
|
||||
}
|
||||
|
||||
// Update image opacity
|
||||
const imageElement = icon.select("image");
|
||||
if (!imageElement.empty()) {
|
||||
imageElement.attr("opacity", disabled ? 0.5 : 1);
|
||||
}
|
||||
// Update image opacity
|
||||
const imageElement = icon.select("image");
|
||||
if (!imageElement.empty()) {
|
||||
imageElement.attr("opacity", disabled ? 0.5 : 1);
|
||||
}
|
||||
|
||||
// Update cooldown text if applicable
|
||||
const cooldownElement = icon.select(".cooldown-text");
|
||||
if (this.params && !cooldownElement.empty() && item.cooldown) {
|
||||
const cooldown = Math.ceil(item.cooldown(this.params));
|
||||
if (cooldown <= 0) {
|
||||
cooldownElement.remove();
|
||||
} else {
|
||||
cooldownElement.text(cooldown + "s");
|
||||
// Update cooldown text if applicable
|
||||
const cooldownElement = icon.select(".cooldown-text");
|
||||
if (this.params && !cooldownElement.empty() && item.cooldown) {
|
||||
const cooldown = Math.ceil(item.cooldown(this.params));
|
||||
if (cooldown <= 0) {
|
||||
cooldownElement.remove();
|
||||
} else {
|
||||
cooldownElement.text(cooldown + "s");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update timer gradient
|
||||
this.maybeUpdateTimerGradient(item, color, opacity);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1087,6 +1179,172 @@ export class RadialMenu implements Layer {
|
||||
this.updateCenterButtonState(this.centerButtonState);
|
||||
}
|
||||
|
||||
private refreshAllyExtendIcon(
|
||||
item: MenuElement,
|
||||
disabled: boolean,
|
||||
icon: d3.Selection<SVGImageElement, unknown, null, undefined>,
|
||||
): void {
|
||||
if (item.renderType !== "allyExtend" || !this.params) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stateKey = this.getStateKeyByType(
|
||||
item.renderType,
|
||||
disabled,
|
||||
this.params,
|
||||
);
|
||||
const prevState = icon.attr("data-prev-state");
|
||||
|
||||
if (stateKey && stateKey === prevState) {
|
||||
// State unchanged, skip re-render to preserve animations
|
||||
} else {
|
||||
const cx = parseFloat(icon.attr("data-cx") || "0");
|
||||
const cy = parseFloat(icon.attr("data-cy") || "0");
|
||||
|
||||
if (stateKey) {
|
||||
icon.attr("data-prev-state", stateKey);
|
||||
} else {
|
||||
icon.selectAll("*").remove();
|
||||
}
|
||||
|
||||
this.renderAllyExtendIcon(
|
||||
icon.node()! as SVGGElement,
|
||||
cx,
|
||||
cy,
|
||||
this.config.iconSize,
|
||||
disabled,
|
||||
this.params,
|
||||
item.icon,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private maybeUpdateTimerGradient(
|
||||
item: MenuElement,
|
||||
color: string,
|
||||
opacity: number,
|
||||
): void {
|
||||
if (!item.timerFraction || !this.params) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fraction = item.timerFraction(this.params);
|
||||
const gradient = this.menuElement.select(`#timer-gradient-${item.id}`);
|
||||
if (!gradient.empty()) {
|
||||
const offset = 1 - fraction;
|
||||
const normalColor =
|
||||
d3.color(color)?.copy({ opacity: opacity })?.toString() ?? color;
|
||||
const interpolated = d3.color(d3.interpolateRgb(color, "white")(0.4));
|
||||
const fadedColor =
|
||||
interpolated?.copy({ opacity })?.toString() ?? normalColor;
|
||||
|
||||
gradient
|
||||
.select(".timer-stop-faded")
|
||||
.attr("offset", offset)
|
||||
.attr("stop-color", fadedColor);
|
||||
gradient
|
||||
.select(".timer-stop-normal")
|
||||
.attr("offset", offset)
|
||||
.attr("stop-color", normalColor);
|
||||
}
|
||||
}
|
||||
|
||||
private getStateKeyByType(
|
||||
type: string,
|
||||
disabled: boolean,
|
||||
params: MenuElementParams,
|
||||
): string | null {
|
||||
switch (type) {
|
||||
case "allyExtend":
|
||||
return this.getAllyExtendStateKey(disabled, params);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private getAllyExtendStateKey(
|
||||
disabled: boolean,
|
||||
params: MenuElementParams,
|
||||
): string {
|
||||
const interaction = params.playerActions?.interaction;
|
||||
const myAgreed = interaction?.allianceInfo?.myPlayerAgreedToExtend ?? false;
|
||||
const otherAgreed = interaction?.allianceInfo?.otherAgreedToExtend ?? false;
|
||||
return `${disabled}:${myAgreed}:${otherAgreed}`;
|
||||
}
|
||||
|
||||
private renderAllyExtendIcon(
|
||||
content: SVGGElement,
|
||||
cx: number,
|
||||
cy: number,
|
||||
iconSize: number,
|
||||
disabled: boolean,
|
||||
params: MenuElementParams,
|
||||
icon?: string,
|
||||
update?: boolean,
|
||||
): void {
|
||||
if (update) {
|
||||
while (content.firstChild) content.removeChild(content.firstChild);
|
||||
}
|
||||
|
||||
const interaction = params.playerActions?.interaction;
|
||||
const myAgreed = interaction?.allianceInfo?.myPlayerAgreedToExtend ?? false;
|
||||
const otherAgreed = interaction?.allianceInfo?.otherAgreedToExtend ?? false;
|
||||
|
||||
const ns = "http://www.w3.org/2000/svg";
|
||||
const smallSize = iconSize * 0.8;
|
||||
const iconUrl = icon ?? "";
|
||||
|
||||
getSvgAspectRatio(iconUrl).then((ratio) => {
|
||||
const width = smallSize * (ratio ?? 1);
|
||||
const gap = 2;
|
||||
const totalWidth = width * 2 + gap;
|
||||
|
||||
// Left handshake = me
|
||||
const leftImg = document.createElementNS(ns, "image");
|
||||
leftImg.setAttribute("href", iconUrl);
|
||||
leftImg.setAttribute("width", width.toString());
|
||||
leftImg.setAttribute("height", smallSize.toString());
|
||||
leftImg.setAttribute("x", (cx - totalWidth / 2).toString());
|
||||
leftImg.setAttribute("y", (cy - smallSize / 2).toString());
|
||||
leftImg.setAttribute("opacity", disabled ? "0.5" : "1");
|
||||
|
||||
if (!myAgreed) {
|
||||
const animLeft = document.createElementNS(ns, "animate");
|
||||
animLeft.setAttribute("attributeName", "opacity");
|
||||
animLeft.setAttribute("values", disabled ? "0.5;0.1;0.5" : "1;0.2;1");
|
||||
animLeft.setAttribute("dur", "1.5s");
|
||||
animLeft.setAttribute("repeatCount", "indefinite");
|
||||
leftImg.appendChild(animLeft);
|
||||
}
|
||||
|
||||
content.appendChild(leftImg);
|
||||
|
||||
// Right handshake = them
|
||||
const rightImg = document.createElementNS(ns, "image");
|
||||
rightImg.setAttribute("href", iconUrl);
|
||||
rightImg.setAttribute("width", width.toString());
|
||||
rightImg.setAttribute("height", smallSize.toString());
|
||||
rightImg.setAttribute(
|
||||
"x",
|
||||
(cx - totalWidth / 2 + width + gap).toString(),
|
||||
);
|
||||
rightImg.setAttribute("y", (cy - smallSize / 2).toString());
|
||||
rightImg.setAttribute("opacity", disabled ? "0.5" : "1");
|
||||
|
||||
if (!otherAgreed) {
|
||||
const animRight = document.createElementNS(ns, "animate");
|
||||
animRight.setAttribute("attributeName", "opacity");
|
||||
animRight.setAttribute("values", disabled ? "0.5;0.1;0.5" : "1;0.2;1");
|
||||
animRight.setAttribute("dur", "1.5s");
|
||||
animRight.setAttribute("repeatCount", "indefinite");
|
||||
rightImg.appendChild(animRight);
|
||||
}
|
||||
|
||||
content.appendChild(rightImg);
|
||||
});
|
||||
}
|
||||
|
||||
renderLayer(context: CanvasRenderingContext2D) {
|
||||
// No need to render anything on the canvas
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { Config } from "../../../core/configuration/Config";
|
||||
import { AllPlayers, PlayerActions, UnitType } from "../../../core/game/Game";
|
||||
import {
|
||||
AllPlayers,
|
||||
BuildableAttacks,
|
||||
PlayerActions,
|
||||
PlayerBuildableUnitType,
|
||||
Structures,
|
||||
UnitType,
|
||||
} from "../../../core/game/Game";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import { GameView, PlayerView } from "../../../core/game/GameView";
|
||||
import { Emoji, flattenedEmojiTable } from "../../../core/Util";
|
||||
@@ -46,7 +53,7 @@ export interface MenuElement {
|
||||
id: string;
|
||||
name: string;
|
||||
displayed?: boolean | ((params: MenuElementParams) => boolean);
|
||||
color?: string;
|
||||
color?: string | ((params: MenuElementParams) => string);
|
||||
icon?: string;
|
||||
text?: string;
|
||||
fontSize?: string;
|
||||
@@ -57,6 +64,10 @@ export interface MenuElement {
|
||||
disabled: (params: MenuElementParams) => boolean;
|
||||
action?: (params: MenuElementParams) => void; // For leaf items that perform actions
|
||||
subMenu?: (params: MenuElementParams) => MenuElement[]; // For non-leaf items that open submenus
|
||||
|
||||
renderType?: string;
|
||||
|
||||
timerFraction?: (params: MenuElementParams) => number; // 0..1, for arc timer overlay
|
||||
}
|
||||
|
||||
export interface TooltipKey {
|
||||
@@ -76,6 +87,7 @@ export const COLORS = {
|
||||
boat: "#3f6ab1",
|
||||
ally: "#53ac75",
|
||||
breakAlly: "#c74848",
|
||||
breakAllyNoDebuff: "#d4882b",
|
||||
delete: "#ff0000",
|
||||
info: "#64748B",
|
||||
target: "#ff0000",
|
||||
@@ -209,6 +221,36 @@ const allyRequestElement: MenuElement = {
|
||||
},
|
||||
};
|
||||
|
||||
const allyExtendElement: MenuElement = {
|
||||
id: "ally_extend",
|
||||
name: "extend",
|
||||
displayed: (params: MenuElementParams) =>
|
||||
!!params.playerActions?.interaction?.allianceInfo?.inExtensionWindow,
|
||||
disabled: (params: MenuElementParams) =>
|
||||
!params.playerActions?.interaction?.allianceInfo?.canExtend,
|
||||
color: COLORS.ally,
|
||||
icon: allianceIcon,
|
||||
action: (params: MenuElementParams) => {
|
||||
if (!params.playerActions?.interaction?.allianceInfo?.canExtend) return;
|
||||
params.playerActionHandler.handleExtendAlliance(params.selected!);
|
||||
params.closeMenu();
|
||||
},
|
||||
timerFraction: (params: MenuElementParams): number => {
|
||||
const interaction = params.playerActions?.interaction;
|
||||
if (!interaction?.allianceInfo) return 1;
|
||||
const remaining = Math.max(
|
||||
0,
|
||||
interaction.allianceInfo.expiresAt - params.game.ticks(),
|
||||
);
|
||||
const extensionWindow = Math.max(
|
||||
1,
|
||||
params.game.config().allianceExtensionPromptOffset(),
|
||||
);
|
||||
return Math.max(0, Math.min(1, remaining / extensionWindow));
|
||||
},
|
||||
renderType: "allyExtend",
|
||||
};
|
||||
|
||||
const allyBreakElement: MenuElement = {
|
||||
id: "ally_break",
|
||||
name: "break",
|
||||
@@ -216,7 +258,10 @@ const allyBreakElement: MenuElement = {
|
||||
!params.playerActions?.interaction?.canBreakAlliance,
|
||||
displayed: (params: MenuElementParams) =>
|
||||
!!params.playerActions?.interaction?.canBreakAlliance,
|
||||
color: COLORS.breakAlly,
|
||||
color: (params: MenuElementParams) =>
|
||||
params.selected?.isTraitor() || params.selected?.isDisconnected()
|
||||
? COLORS.breakAllyNoDebuff
|
||||
: COLORS.breakAlly,
|
||||
icon: traitorIcon,
|
||||
action: (params: MenuElementParams) => {
|
||||
params.playerActionHandler.handleBreakAlliance(
|
||||
@@ -333,45 +378,34 @@ export const infoMenuElement: MenuElement = {
|
||||
},
|
||||
};
|
||||
|
||||
function getAllEnabledUnits(myPlayer: boolean, config: Config): Set<UnitType> {
|
||||
const Units: Set<UnitType> = new Set<UnitType>();
|
||||
function getAllEnabledUnits(
|
||||
myPlayer: boolean,
|
||||
config: Config,
|
||||
): Set<PlayerBuildableUnitType> {
|
||||
const units: Set<PlayerBuildableUnitType> =
|
||||
new Set<PlayerBuildableUnitType>();
|
||||
|
||||
const addStructureIfEnabled = (unitType: UnitType) => {
|
||||
const addIfEnabled = (unitType: PlayerBuildableUnitType) => {
|
||||
if (!config.isUnitDisabled(unitType)) {
|
||||
Units.add(unitType);
|
||||
units.add(unitType);
|
||||
}
|
||||
};
|
||||
|
||||
if (myPlayer) {
|
||||
addStructureIfEnabled(UnitType.City);
|
||||
addStructureIfEnabled(UnitType.DefensePost);
|
||||
addStructureIfEnabled(UnitType.Port);
|
||||
addStructureIfEnabled(UnitType.MissileSilo);
|
||||
addStructureIfEnabled(UnitType.SAMLauncher);
|
||||
addStructureIfEnabled(UnitType.Factory);
|
||||
Structures.types.forEach(addIfEnabled);
|
||||
} else {
|
||||
addStructureIfEnabled(UnitType.Warship);
|
||||
addStructureIfEnabled(UnitType.HydrogenBomb);
|
||||
addStructureIfEnabled(UnitType.MIRV);
|
||||
addStructureIfEnabled(UnitType.AtomBomb);
|
||||
BuildableAttacks.types.forEach(addIfEnabled);
|
||||
}
|
||||
|
||||
return Units;
|
||||
return units;
|
||||
}
|
||||
|
||||
const ATTACK_UNIT_TYPES: UnitType[] = [
|
||||
UnitType.AtomBomb,
|
||||
UnitType.MIRV,
|
||||
UnitType.HydrogenBomb,
|
||||
UnitType.Warship,
|
||||
];
|
||||
|
||||
function createMenuElements(
|
||||
params: MenuElementParams,
|
||||
filterType: "attack" | "build",
|
||||
elementIdPrefix: string,
|
||||
): MenuElement[] {
|
||||
const unitTypes: Set<UnitType> = getAllEnabledUnits(
|
||||
const unitTypes: Set<PlayerBuildableUnitType> = getAllEnabledUnits(
|
||||
params.selected === params.myPlayer,
|
||||
params.game.config(),
|
||||
);
|
||||
@@ -381,51 +415,53 @@ function createMenuElements(
|
||||
(item) =>
|
||||
unitTypes.has(item.unitType) &&
|
||||
(filterType === "attack"
|
||||
? ATTACK_UNIT_TYPES.includes(item.unitType)
|
||||
: !ATTACK_UNIT_TYPES.includes(item.unitType)),
|
||||
? BuildableAttacks.has(item.unitType)
|
||||
: !BuildableAttacks.has(item.unitType)),
|
||||
)
|
||||
.map((item: BuildItemDisplay) => ({
|
||||
id: `${elementIdPrefix}_${item.unitType}`,
|
||||
name: item.key
|
||||
? item.key.replace("unit_type.", "")
|
||||
: item.unitType.toString(),
|
||||
disabled: (params: MenuElementParams) =>
|
||||
!params.buildMenu.canBuildOrUpgrade(item),
|
||||
color: params.buildMenu.canBuildOrUpgrade(item)
|
||||
? filterType === "attack"
|
||||
? COLORS.attack
|
||||
: COLORS.building
|
||||
: undefined,
|
||||
icon: item.icon,
|
||||
tooltipItems: [
|
||||
{ text: translateText(item.key ?? ""), className: "title" },
|
||||
{
|
||||
text: translateText(item.description ?? ""),
|
||||
className: "description",
|
||||
.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,
|
||||
icon: item.icon,
|
||||
tooltipItems: [
|
||||
{ text: translateText(item.key ?? ""), className: "title" },
|
||||
{
|
||||
text: translateText(item.description ?? ""),
|
||||
className: "description",
|
||||
},
|
||||
{
|
||||
text: `${renderNumber(params.buildMenu.cost(item))} ${translateText("player_panel.gold")}`,
|
||||
className: "cost",
|
||||
},
|
||||
item.countable
|
||||
? { text: `${params.buildMenu.count(item)}x`, className: "count" }
|
||||
: null,
|
||||
].filter(
|
||||
(tooltipItem): tooltipItem is TooltipItem => tooltipItem !== null,
|
||||
),
|
||||
action: (params: MenuElementParams) => {
|
||||
const buildableUnit = params.playerActions.buildableUnits.find(
|
||||
(bu) => bu.type === item.unitType,
|
||||
);
|
||||
if (buildableUnit === undefined) {
|
||||
return;
|
||||
}
|
||||
if (canBuildOrUpgrade) {
|
||||
params.buildMenu.sendBuildOrUpgrade(buildableUnit, params.tile);
|
||||
}
|
||||
params.closeMenu();
|
||||
},
|
||||
{
|
||||
text: `${renderNumber(params.buildMenu.cost(item))} ${translateText("player_panel.gold")}`,
|
||||
className: "cost",
|
||||
},
|
||||
item.countable
|
||||
? { text: `${params.buildMenu.count(item)}x`, className: "count" }
|
||||
: null,
|
||||
].filter(
|
||||
(tooltipItem): tooltipItem is TooltipItem => tooltipItem !== null,
|
||||
),
|
||||
action: (params: MenuElementParams) => {
|
||||
const buildableUnit = params.playerActions.buildableUnits.find(
|
||||
(bu) => bu.type === item.unitType,
|
||||
);
|
||||
if (buildableUnit === undefined) {
|
||||
return;
|
||||
}
|
||||
if (params.buildMenu.canBuildOrUpgrade(item)) {
|
||||
params.buildMenu.sendBuildOrUpgrade(buildableUnit, params.tile);
|
||||
}
|
||||
params.closeMenu();
|
||||
},
|
||||
}));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const attackMenuElement: MenuElement = {
|
||||
@@ -625,13 +661,16 @@ export const rootMenuElement: MenuElement = {
|
||||
tileOwner.isPlayer() &&
|
||||
(tileOwner as PlayerView).id() === params.myPlayer.id();
|
||||
|
||||
const inExtensionWindow =
|
||||
params.playerActions.interaction?.allianceInfo?.inExtensionWindow;
|
||||
|
||||
const menuItems: (MenuElement | null)[] = [
|
||||
infoMenuElement,
|
||||
...(isOwnTerritory
|
||||
? [deleteUnitElement, allyRequestElement, buildMenuElement]
|
||||
: [
|
||||
isAllied && !isDisconnected ? allyBreakElement : boatMenuElement,
|
||||
allyRequestElement,
|
||||
inExtensionWindow ? allyExtendElement : allyRequestElement,
|
||||
isFriendlyTarget(params) && !isDisconnected
|
||||
? donateGoldRadialElement
|
||||
: attackMenuElement,
|
||||
|
||||
@@ -199,9 +199,6 @@ export class RailroadLayer implements Layer {
|
||||
if (scale <= 1) {
|
||||
return;
|
||||
}
|
||||
if (this.existingRailroads.size === 0) {
|
||||
return;
|
||||
}
|
||||
this.updateRailColors();
|
||||
const rawAlpha = (scale - 1) / (2 - 1); // maps 1->0, 2->1
|
||||
const alpha = Math.max(0, Math.min(1, rawAlpha));
|
||||
@@ -228,21 +225,74 @@ export class RailroadLayer implements Layer {
|
||||
|
||||
context.save();
|
||||
context.globalAlpha = alpha;
|
||||
this.highlightOverlappingRailroads(context);
|
||||
context.drawImage(
|
||||
this.canvas,
|
||||
srcX,
|
||||
srcY,
|
||||
srcW,
|
||||
srcH,
|
||||
dstX,
|
||||
dstY,
|
||||
visWidth,
|
||||
visHeight,
|
||||
);
|
||||
|
||||
this.renderGhostRailroads(context);
|
||||
|
||||
if (this.existingRailroads.size > 0) {
|
||||
this.highlightOverlappingRailroads(context);
|
||||
|
||||
context.drawImage(
|
||||
this.canvas,
|
||||
srcX,
|
||||
srcY,
|
||||
srcW,
|
||||
srcH,
|
||||
dstX,
|
||||
dstY,
|
||||
visWidth,
|
||||
visHeight,
|
||||
);
|
||||
}
|
||||
|
||||
context.restore();
|
||||
}
|
||||
|
||||
private renderGhostRailroads(context: CanvasRenderingContext2D) {
|
||||
if (
|
||||
this.uiState.ghostStructure !== UnitType.City &&
|
||||
this.uiState.ghostStructure !== UnitType.Port
|
||||
)
|
||||
return;
|
||||
if (this.uiState.ghostRailPaths.length === 0) return;
|
||||
|
||||
const offsetX = -this.game.width() / 2;
|
||||
const offsetY = -this.game.height() / 2;
|
||||
context.fillStyle = "rgba(0, 0, 0, 0.4)";
|
||||
|
||||
for (const path of this.uiState.ghostRailPaths) {
|
||||
const railTiles = computeRailTiles(this.game, path);
|
||||
for (const railTile of railTiles) {
|
||||
const x = this.game.x(railTile.tile);
|
||||
const y = this.game.y(railTile.tile);
|
||||
|
||||
if (this.game.isWater(railTile.tile)) {
|
||||
context.save();
|
||||
context.fillStyle = "rgba(197, 69, 72, 0.4)";
|
||||
const bridgeRects = getBridgeRects(railTile.type);
|
||||
for (const [dx, dy, w, h] of bridgeRects) {
|
||||
context.fillRect(
|
||||
x + offsetX + dx / 2,
|
||||
y + offsetY + dy / 2,
|
||||
w / 2,
|
||||
h / 2,
|
||||
);
|
||||
}
|
||||
context.restore();
|
||||
}
|
||||
|
||||
const railRects = getRailroadRects(railTile.type);
|
||||
for (const [dx, dy, w, h] of railRects) {
|
||||
context.fillRect(
|
||||
x + offsetX + dx / 2,
|
||||
y + offsetY + dy / 2,
|
||||
w / 2,
|
||||
h / 2,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onRailroadSnapEvent(update: RailroadSnapUpdate) {
|
||||
const original = this.railroads.get(update.originalId);
|
||||
if (!original) {
|
||||
|
||||
@@ -40,9 +40,9 @@ function horizontalRailroadRects(): number[][] {
|
||||
function verticalRailroadRects(): number[][] {
|
||||
// x/y/w/h
|
||||
const rects = [
|
||||
[-1, -2, 1, 2],
|
||||
[1, -2, 1, 2],
|
||||
[0, -1, 1, 1],
|
||||
[-1, -1, 1, 2],
|
||||
[1, -1, 1, 2],
|
||||
[0, 0, 1, 1],
|
||||
];
|
||||
return rects;
|
||||
}
|
||||
@@ -50,9 +50,9 @@ function verticalRailroadRects(): number[][] {
|
||||
function topRightRailroadCornerRects(): number[][] {
|
||||
// x/y/w/h
|
||||
const rects = [
|
||||
[-1, -2, 1, 2],
|
||||
[-1, -1, 1, 1],
|
||||
[0, -1, 1, 2],
|
||||
[1, -2, 1, 4],
|
||||
[1, -1, 1, 3],
|
||||
];
|
||||
return rects;
|
||||
}
|
||||
@@ -60,9 +60,9 @@ function topRightRailroadCornerRects(): number[][] {
|
||||
function topLeftRailroadCornerRects(): number[][] {
|
||||
// x/y/w/h
|
||||
const rects = [
|
||||
[-1, -2, 1, 4],
|
||||
[-1, -1, 1, 3],
|
||||
[0, -1, 1, 2],
|
||||
[1, -2, 1, 2],
|
||||
[1, -1, 1, 1],
|
||||
];
|
||||
return rects;
|
||||
}
|
||||
@@ -70,9 +70,9 @@ function topLeftRailroadCornerRects(): number[][] {
|
||||
function bottomRightRailroadCornerRects(): number[][] {
|
||||
// x/y/w/h
|
||||
const rects = [
|
||||
[-1, 1, 1, 2],
|
||||
[-1, 1, 1, 1],
|
||||
[0, 0, 1, 2],
|
||||
[1, -1, 1, 4],
|
||||
[1, -1, 1, 3],
|
||||
];
|
||||
return rects;
|
||||
}
|
||||
@@ -80,9 +80,9 @@ function bottomRightRailroadCornerRects(): number[][] {
|
||||
function bottomLeftRailroadCornerRects(): number[][] {
|
||||
// x/y/w/h
|
||||
const rects = [
|
||||
[-1, -1, 1, 4],
|
||||
[-1, -1, 1, 3],
|
||||
[0, 0, 1, 2],
|
||||
[1, 1, 1, 2],
|
||||
[1, 1, 1, 1],
|
||||
];
|
||||
return rects;
|
||||
}
|
||||
@@ -109,8 +109,8 @@ function horizontalBridge(): number[][] {
|
||||
function verticalBridge(): number[][] {
|
||||
// x/y/w/h
|
||||
return [
|
||||
[-2, -2, 1, 3],
|
||||
[2, -2, 1, 3],
|
||||
[-2, -1, 1, 3],
|
||||
[2, -1, 1, 3],
|
||||
];
|
||||
}
|
||||
// ⌞
|
||||
|
||||
@@ -68,7 +68,7 @@ export class ReplayPanel extends LitElement implements Layer {
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="p-2 bg-gray-800/70 backdrop-blur-xs shadow-xs rounded-lg"
|
||||
class="p-2 bg-gray-800/70 backdrop-blur-xs shadow-xs min-[1200px]:rounded-lg rounded-l-lg"
|
||||
@contextmenu=${(e: Event) => e.preventDefault()}
|
||||
>
|
||||
<label class="block mb-2 text-white" translate="no">
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { EventBus, GameEvent } from "../../../core/EventBus";
|
||||
import { GameMode, Team } from "../../../core/game/Game";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
export class SpawnBarVisibleEvent implements GameEvent {
|
||||
constructor(public readonly visible: boolean) {}
|
||||
}
|
||||
|
||||
@customElement("spawn-timer")
|
||||
export class SpawnTimer extends LitElement implements Layer {
|
||||
public game: GameView;
|
||||
public eventBus: EventBus;
|
||||
public transformHandler: TransformHandler;
|
||||
|
||||
private ratios = [0];
|
||||
private _barVisible = false;
|
||||
private colors = ["rgba(0, 128, 255, 0.7)", "rgba(0, 0, 0, 0.5)"];
|
||||
|
||||
private isVisible = false;
|
||||
@@ -37,39 +44,41 @@ export class SpawnTimer extends LitElement implements Layer {
|
||||
this.game.ticks() / this.game.config().numSpawnPhaseTurns(),
|
||||
];
|
||||
this.colors = ["rgba(0, 128, 255, 0.7)"];
|
||||
this.requestUpdate();
|
||||
return;
|
||||
} else {
|
||||
this.ratios = [];
|
||||
this.colors = [];
|
||||
|
||||
if (this.game.config().gameConfig().gameMode === GameMode.Team) {
|
||||
const teamTiles: Map<Team, number> = new Map();
|
||||
for (const player of this.game.players()) {
|
||||
const team = player.team();
|
||||
if (team === null) continue;
|
||||
const tiles = teamTiles.get(team) ?? 0;
|
||||
teamTiles.set(team, tiles + player.numTilesOwned());
|
||||
}
|
||||
|
||||
const theme = this.game.config().theme();
|
||||
const total = sumIterator(teamTiles.values());
|
||||
if (total > 0) {
|
||||
for (const [team, count] of teamTiles) {
|
||||
const ratio = count / total;
|
||||
this.ratios.push(ratio);
|
||||
this.colors.push(theme.teamColor(team).toRgbString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.ratios = [];
|
||||
this.colors = [];
|
||||
|
||||
if (this.game.config().gameConfig().gameMode !== GameMode.Team) {
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
const teamTiles: Map<Team, number> = new Map();
|
||||
for (const player of this.game.players()) {
|
||||
const team = player.team();
|
||||
if (team === null) throw new Error("Team is null");
|
||||
const tiles = teamTiles.get(team) ?? 0;
|
||||
teamTiles.set(team, tiles + player.numTilesOwned());
|
||||
}
|
||||
|
||||
const theme = this.game.config().theme();
|
||||
const total = sumIterator(teamTiles.values());
|
||||
if (total === 0) {
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [team, count] of teamTiles) {
|
||||
const ratio = count / total;
|
||||
this.ratios.push(ratio);
|
||||
this.colors.push(theme.teamColor(team).toRgbString());
|
||||
}
|
||||
this.requestUpdate();
|
||||
this.emitBarVisibility();
|
||||
}
|
||||
|
||||
private emitBarVisibility() {
|
||||
const nowVisible = this.isVisible && this.ratios.length > 0;
|
||||
if (nowVisible !== this._barVisible) {
|
||||
this._barVisible = nowVisible;
|
||||
this.eventBus?.emit(new SpawnBarVisibleEvent(this._barVisible));
|
||||
}
|
||||
}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { crazyGamesSDK } from "src/client/CrazyGamesSDK";
|
||||
import { Platform } from "src/client/Platform";
|
||||
import { getGamesPlayed } from "src/client/Utils";
|
||||
import { GameType } from "src/core/game/Game";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import "../../components/VideoReward";
|
||||
import "../../components/VideoPromo";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
@customElement("spawn-video-ad")
|
||||
@@ -21,7 +22,7 @@ export class SpawnVideoAd extends LitElement implements Layer {
|
||||
init() {
|
||||
if (
|
||||
!window.adsEnabled ||
|
||||
window.innerWidth < 768 ||
|
||||
Platform.isMobileWidth ||
|
||||
crazyGamesSDK.isOnCrazyGames() ||
|
||||
this.game.config().gameConfig().gameType === GameType.Singleplayer ||
|
||||
getGamesPlayed() < 3 // Don't show to new players
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import * as PIXI from "pixi.js";
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
import { Cell, UnitType } from "../../../core/game/Game";
|
||||
import {
|
||||
Cell,
|
||||
PlayerBuildableUnitType,
|
||||
UnitType,
|
||||
} from "../../../core/game/Game";
|
||||
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import anchorIcon from "/images/AnchorIcon.png?url";
|
||||
@@ -108,7 +112,7 @@ export class SpriteFactory {
|
||||
player: PlayerView,
|
||||
ghostStage: PIXI.Container,
|
||||
pos: { x: number; y: number },
|
||||
structureType: UnitType,
|
||||
structureType: PlayerBuildableUnitType,
|
||||
): {
|
||||
container: PIXI.Container;
|
||||
priceText: PIXI.BitmapText;
|
||||
|
||||
@@ -8,8 +8,9 @@ import { wouldNukeBreakAlliance } from "../../../core/execution/Util";
|
||||
import {
|
||||
BuildableUnit,
|
||||
Cell,
|
||||
PlayerActions,
|
||||
PlayerBuildableUnitType,
|
||||
PlayerID,
|
||||
Structures,
|
||||
UnitType,
|
||||
} from "../../../core/game/Game";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
@@ -74,25 +75,24 @@ export class StructureIconsLayer implements Layer {
|
||||
private ghostStage: PIXI.Container;
|
||||
private levelsStage: PIXI.Container;
|
||||
private rootStage: PIXI.Container = new PIXI.Container();
|
||||
public playerActions: PlayerActions | null = null;
|
||||
private dotsStage: PIXI.Container;
|
||||
private readonly theme: Theme;
|
||||
private renderer: PIXI.Renderer | null = null;
|
||||
private rendererInitialized: boolean = false;
|
||||
private renders: StructureRenderInfo[] = [];
|
||||
private readonly seenUnits: Set<UnitView> = new Set();
|
||||
private readonly rendersByUnitId: Map<number, StructureRenderInfo> =
|
||||
new Map();
|
||||
private readonly seenUnitIds: Set<number> = new Set();
|
||||
private readonly connectedAllySmallIds: Set<number> = new Set();
|
||||
private readonly mousePos = { x: 0, y: 0 };
|
||||
private renderSprites = true;
|
||||
private factory: SpriteFactory;
|
||||
private readonly structures: Map<UnitType, { visible: boolean }> = new Map([
|
||||
[UnitType.City, { visible: true }],
|
||||
[UnitType.Factory, { visible: true }],
|
||||
[UnitType.DefensePost, { visible: true }],
|
||||
[UnitType.Port, { visible: true }],
|
||||
[UnitType.MissileSilo, { visible: true }],
|
||||
[UnitType.SAMLauncher, { visible: true }],
|
||||
]);
|
||||
private readonly structures: Map<
|
||||
PlayerBuildableUnitType,
|
||||
{ visible: boolean }
|
||||
> = new Map(Structures.types.map((type) => [type, { visible: true }]));
|
||||
private lastGhostQueryAt: number;
|
||||
private visibilityStateDirty = true;
|
||||
private hasHiddenStructure = false;
|
||||
potentialUpgrade: StructureRenderInfo | undefined;
|
||||
|
||||
constructor(
|
||||
@@ -187,18 +187,22 @@ export class StructureIconsLayer implements Layer {
|
||||
}
|
||||
|
||||
tick() {
|
||||
this.game
|
||||
.updatesSinceLastTick()
|
||||
?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id))
|
||||
?.forEach((unitView) => {
|
||||
if (unitView === undefined) return;
|
||||
const unitUpdates = this.game.updatesSinceLastTick()?.[GameUpdateType.Unit];
|
||||
if (unitUpdates) {
|
||||
for (let i = 0, len = unitUpdates.length; i < len; i++) {
|
||||
const unitView = this.game.unit(unitUpdates[i].id);
|
||||
if (unitView === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const unitId = unitView.id();
|
||||
if (unitView.isActive()) {
|
||||
this.handleActiveUnit(unitView);
|
||||
} else if (this.seenUnits.has(unitView)) {
|
||||
} else if (this.seenUnitIds.has(unitId)) {
|
||||
this.handleInactiveUnit(unitView);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
this.renderSprites =
|
||||
this.game.config().userSettings()?.structureSprites() ?? true;
|
||||
}
|
||||
@@ -226,7 +230,7 @@ export class StructureIconsLayer implements Layer {
|
||||
this.renderGhost();
|
||||
|
||||
if (this.transformHandler.hasChanged()) {
|
||||
for (const render of this.renders) {
|
||||
for (const render of this.rendersByUnitId.values()) {
|
||||
this.computeNewLocation(render);
|
||||
}
|
||||
}
|
||||
@@ -271,13 +275,21 @@ export class StructureIconsLayer implements Layer {
|
||||
(nukeType === UnitType.AtomBomb || nukeType === UnitType.HydrogenBomb)
|
||||
) {
|
||||
// Only check connected allies - nuking disconnected allies doesn't cause a traitor debuff
|
||||
const allies = myPlayer.allies().filter((a) => !a.isDisconnected());
|
||||
if (allies.length > 0) {
|
||||
this.connectedAllySmallIds.clear();
|
||||
const allies = myPlayer.allies();
|
||||
for (let i = 0; i < allies.length; i++) {
|
||||
const ally = allies[i];
|
||||
if (!ally.isDisconnected()) {
|
||||
this.connectedAllySmallIds.add(ally.smallID());
|
||||
}
|
||||
}
|
||||
|
||||
if (this.connectedAllySmallIds.size > 0) {
|
||||
targetingAlly = wouldNukeBreakAlliance({
|
||||
game: this.game,
|
||||
targetTile: tileRef,
|
||||
magnitude: this.game.config().nukeMagnitudes(nukeType),
|
||||
allySmallIds: new Set(allies.map((a) => a.smallID())),
|
||||
allySmallIds: this.connectedAllySmallIds,
|
||||
threshold: this.game.config().nukeAllianceBreakThreshold(),
|
||||
});
|
||||
}
|
||||
@@ -285,8 +297,8 @@ export class StructureIconsLayer implements Layer {
|
||||
|
||||
this.game
|
||||
?.myPlayer()
|
||||
?.actions(tileRef)
|
||||
.then((actions) => {
|
||||
?.buildables(tileRef, [this.ghostUnit?.buildableUnit.type])
|
||||
.then((buildables) => {
|
||||
if (this.potentialUpgrade) {
|
||||
this.potentialUpgrade.iconContainer.filters = [];
|
||||
this.potentialUpgrade.dotContainer.filters = [];
|
||||
@@ -297,7 +309,7 @@ export class StructureIconsLayer implements Layer {
|
||||
|
||||
if (!this.ghostUnit) return;
|
||||
|
||||
const unit = actions.buildableUnits.find(
|
||||
const unit = buildables.find(
|
||||
(u) => u.type === this.ghostUnit!.buildableUnit.type,
|
||||
);
|
||||
const showPrice = this.game.config().userSettings().cursorCostLabel();
|
||||
@@ -320,11 +332,14 @@ export class StructureIconsLayer implements Layer {
|
||||
this.updateGhostRange(targetLevel, targetingAlly);
|
||||
|
||||
if (unit.canUpgrade) {
|
||||
this.potentialUpgrade = this.renders.find(
|
||||
(r) =>
|
||||
r.unit.id() === unit.canUpgrade &&
|
||||
r.unit.owner().id() === this.game.myPlayer()?.id(),
|
||||
);
|
||||
this.potentialUpgrade = this.rendersByUnitId.get(unit.canUpgrade);
|
||||
if (
|
||||
this.potentialUpgrade &&
|
||||
this.potentialUpgrade.unit.owner().id() !==
|
||||
this.game.myPlayer()?.id()
|
||||
) {
|
||||
this.potentialUpgrade = undefined;
|
||||
}
|
||||
if (this.potentialUpgrade) {
|
||||
this.potentialUpgrade.iconContainer.filters = [
|
||||
new OutlineFilter({ thickness: 2, color: "rgba(0, 255, 0, 1)" }),
|
||||
@@ -335,13 +350,16 @@ export class StructureIconsLayer implements Layer {
|
||||
}
|
||||
// No overlapping when a structure is upgradable
|
||||
this.uiState.overlappingRailroads = [];
|
||||
this.uiState.ghostRailPaths = [];
|
||||
} else if (unit.canBuild === false) {
|
||||
this.ghostUnit.container.filters = [
|
||||
new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }),
|
||||
];
|
||||
this.uiState.overlappingRailroads = [];
|
||||
this.uiState.ghostRailPaths = [];
|
||||
} else {
|
||||
this.uiState.overlappingRailroads = unit.overlappingRailroads;
|
||||
this.uiState.ghostRailPaths = unit.ghostRailPaths;
|
||||
}
|
||||
|
||||
const scale = this.transformHandler.scale;
|
||||
@@ -433,7 +451,7 @@ export class StructureIconsLayer implements Layer {
|
||||
this.ghostUnit.range?.position.set(localX, localY);
|
||||
}
|
||||
|
||||
private createGhostStructure(type: UnitType | null) {
|
||||
private createGhostStructure(type: PlayerBuildableUnitType | null) {
|
||||
const player = this.game.myPlayer();
|
||||
if (!player) return;
|
||||
if (type === null) {
|
||||
@@ -461,6 +479,7 @@ export class StructureIconsLayer implements Layer {
|
||||
canUpgrade: false,
|
||||
cost: 0n,
|
||||
overlappingRailroads: [],
|
||||
ghostRailPaths: [],
|
||||
},
|
||||
};
|
||||
const showPrice = this.game.config().userSettings().cursorCostLabel();
|
||||
@@ -480,6 +499,7 @@ export class StructureIconsLayer implements Layer {
|
||||
this.potentialUpgrade.dotContainer.filters = [];
|
||||
this.potentialUpgrade = undefined;
|
||||
}
|
||||
this.uiState.ghostRailPaths = [];
|
||||
}
|
||||
|
||||
private removeGhostStructure() {
|
||||
@@ -537,25 +557,44 @@ export class StructureIconsLayer implements Layer {
|
||||
}
|
||||
}
|
||||
|
||||
private toggleStructures(toggleStructureType: UnitType[] | null): void {
|
||||
private toggleStructures(
|
||||
toggleStructureType: PlayerBuildableUnitType[] | null,
|
||||
): void {
|
||||
for (const [structureType, infos] of this.structures) {
|
||||
infos.visible =
|
||||
toggleStructureType?.indexOf(structureType) !== -1 ||
|
||||
toggleStructureType === null;
|
||||
}
|
||||
for (const render of this.renders) {
|
||||
this.visibilityStateDirty = true;
|
||||
for (const render of this.rendersByUnitId.values()) {
|
||||
this.modifyVisibility(render);
|
||||
}
|
||||
}
|
||||
|
||||
private refreshVisibilityStateCache() {
|
||||
if (!this.visibilityStateDirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.hasHiddenStructure = false;
|
||||
for (const infos of this.structures.values()) {
|
||||
if (infos.visible === false) {
|
||||
this.hasHiddenStructure = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.visibilityStateDirty = false;
|
||||
}
|
||||
|
||||
private findRenderByUnit(
|
||||
unitView: UnitView,
|
||||
): StructureRenderInfo | undefined {
|
||||
return this.renders.find((render) => render.unit.id() === unitView.id());
|
||||
return this.rendersByUnitId.get(unitView.id());
|
||||
}
|
||||
|
||||
private handleActiveUnit(unitView: UnitView) {
|
||||
if (this.seenUnits.has(unitView)) {
|
||||
if (this.seenUnitIds.has(unitView.id())) {
|
||||
const render = this.findRenderByUnit(unitView);
|
||||
if (render) {
|
||||
this.checkForConstructionState(render, unitView);
|
||||
@@ -563,12 +602,18 @@ export class StructureIconsLayer implements Layer {
|
||||
this.checkForOwnershipChange(render, unitView);
|
||||
this.checkForLevelChange(render, unitView);
|
||||
}
|
||||
} else if (this.structures.has(unitView.type())) {
|
||||
} else if (
|
||||
this.structures.has(unitView.type() as PlayerBuildableUnitType)
|
||||
) {
|
||||
this.addNewStructure(unitView);
|
||||
}
|
||||
}
|
||||
|
||||
private handleInactiveUnit(unitView: UnitView) {
|
||||
if (!this.seenUnitIds.has(unitView.id())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const render = this.findRenderByUnit(unitView);
|
||||
if (render) {
|
||||
this.deleteStructure(render);
|
||||
@@ -576,20 +621,15 @@ export class StructureIconsLayer implements Layer {
|
||||
}
|
||||
|
||||
private modifyVisibility(render: StructureRenderInfo) {
|
||||
const structureType = render.unit.type();
|
||||
this.refreshVisibilityStateCache();
|
||||
|
||||
const structureType = render.unit.type() as PlayerBuildableUnitType;
|
||||
const structureInfos = this.structures.get(structureType);
|
||||
|
||||
let focusStructure = false;
|
||||
for (const infos of this.structures.values()) {
|
||||
if (infos.visible === false) {
|
||||
focusStructure = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (structureInfos) {
|
||||
render.iconContainer.alpha = structureInfos.visible ? 1 : 0.3;
|
||||
render.dotContainer.alpha = structureInfos.visible ? 1 : 0.3;
|
||||
if (structureInfos.visible && focusStructure) {
|
||||
if (structureInfos.visible && this.hasHiddenStructure) {
|
||||
render.iconContainer.filters = [
|
||||
new OutlineFilter({ thickness: 2, color: "rgb(255, 255, 255)" }),
|
||||
];
|
||||
@@ -711,7 +751,7 @@ export class StructureIconsLayer implements Layer {
|
||||
}
|
||||
|
||||
private addNewStructure(unitView: UnitView) {
|
||||
this.seenUnits.add(unitView);
|
||||
this.seenUnitIds.add(unitView.id());
|
||||
const render = new StructureRenderInfo(
|
||||
unitView,
|
||||
unitView.owner().id(),
|
||||
@@ -721,7 +761,7 @@ export class StructureIconsLayer implements Layer {
|
||||
unitView.level(),
|
||||
unitView.isUnderConstruction(),
|
||||
);
|
||||
this.renders.push(render);
|
||||
this.rendersByUnitId.set(unitView.id(), render);
|
||||
this.computeNewLocation(render);
|
||||
this.modifyVisibility(render);
|
||||
}
|
||||
@@ -751,7 +791,11 @@ export class StructureIconsLayer implements Layer {
|
||||
render.iconContainer?.destroy();
|
||||
render.levelContainer?.destroy();
|
||||
render.dotContainer?.destroy();
|
||||
this.renders = this.renders.filter((r) => r.unit !== render.unit);
|
||||
this.seenUnits.delete(render.unit);
|
||||
const unitId = render.unit.id();
|
||||
this.rendersByUnitId.delete(unitId);
|
||||
this.seenUnitIds.delete(unitId);
|
||||
if (this.potentialUpgrade?.unit.id() === unitId) {
|
||||
this.potentialUpgrade = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,14 +69,18 @@ export class TeamStats extends LitElement implements Layer {
|
||||
}
|
||||
|
||||
for (const player of players) {
|
||||
const team = player.team();
|
||||
if (team === null) continue;
|
||||
grouped[team] ??= [];
|
||||
grouped[team].push(player);
|
||||
const rawTeam = player.team();
|
||||
if (rawTeam === null) continue;
|
||||
grouped[rawTeam] ??= [];
|
||||
grouped[rawTeam].push(player);
|
||||
}
|
||||
|
||||
this.teams = Object.entries(grouped)
|
||||
.map(([teamStr, teamPlayers]) => {
|
||||
.map(([rawTeam, teamPlayers]) => {
|
||||
const key = `team_colors.${rawTeam.toLowerCase()}`;
|
||||
const translated = translateText(key);
|
||||
const teamName = translated !== key ? translated : rawTeam;
|
||||
|
||||
let totalGold = 0n;
|
||||
let totalMaxTroops = 0;
|
||||
let totalScoreSort = 0;
|
||||
@@ -102,8 +106,8 @@ export class TeamStats extends LitElement implements Layer {
|
||||
const totalScorePercent = totalScoreSort / numTilesWithoutFallout;
|
||||
|
||||
return {
|
||||
teamName: teamStr,
|
||||
isMyTeam: teamStr === this._myTeam,
|
||||
teamName,
|
||||
isMyTeam: rawTeam === this._myTeam,
|
||||
totalScoreStr: formatPercentage(totalScorePercent),
|
||||
totalScoreSort,
|
||||
totalGold: renderNumber(totalGold),
|
||||
@@ -132,7 +136,7 @@ export class TeamStats extends LitElement implements Layer {
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="max-h-[30vh] overflow-y-auto grid bg-slate-800/70 w-full text-white text-xs md:text-sm mt-2"
|
||||
class="max-h-[30vh] overflow-x-hidden overflow-y-auto grid bg-slate-800/85 w-full text-white text-xs md:text-sm mt-2 rounded-lg"
|
||||
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
|
||||
>
|
||||
<div
|
||||
@@ -140,7 +144,7 @@ export class TeamStats extends LitElement implements Layer {
|
||||
style="--cols:${this.showUnits ? 5 : 4};"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="contents font-bold bg-slate-700/50">
|
||||
<div class="contents font-bold bg-slate-700/60">
|
||||
<div class="p-1.5 md:p-2.5 text-center border-b border-slate-500">
|
||||
${translateText("leaderboard.team")}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { Gold, PlayerActions, UnitType } from "../../../core/game/Game";
|
||||
import {
|
||||
BuildableUnit,
|
||||
BuildMenus,
|
||||
Gold,
|
||||
PlayerBuildableUnitType,
|
||||
UnitType,
|
||||
} from "../../../core/game/Game";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import {
|
||||
GhostStructureChangedEvent,
|
||||
@@ -27,7 +33,7 @@ export class UnitDisplay extends LitElement implements Layer {
|
||||
public game: GameView;
|
||||
public eventBus: EventBus;
|
||||
public uiState: UIState;
|
||||
private playerActions: PlayerActions | null = null;
|
||||
private playerBuildables: BuildableUnit[] | null = null;
|
||||
private keybinds: Record<string, { value: string; key: string }> = {};
|
||||
private _cities = 0;
|
||||
private _warships = 0;
|
||||
@@ -37,7 +43,7 @@ export class UnitDisplay extends LitElement implements Layer {
|
||||
private _defensePost = 0;
|
||||
private _samLauncher = 0;
|
||||
private allDisabled = false;
|
||||
private _hoveredUnit: UnitType | null = null;
|
||||
private _hoveredUnit: PlayerBuildableUnitType | null = null;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
@@ -55,22 +61,12 @@ export class UnitDisplay extends LitElement implements Layer {
|
||||
}
|
||||
}
|
||||
|
||||
this.allDisabled =
|
||||
config.isUnitDisabled(UnitType.City) &&
|
||||
config.isUnitDisabled(UnitType.Factory) &&
|
||||
config.isUnitDisabled(UnitType.Port) &&
|
||||
config.isUnitDisabled(UnitType.DefensePost) &&
|
||||
config.isUnitDisabled(UnitType.MissileSilo) &&
|
||||
config.isUnitDisabled(UnitType.SAMLauncher) &&
|
||||
config.isUnitDisabled(UnitType.Warship) &&
|
||||
config.isUnitDisabled(UnitType.AtomBomb) &&
|
||||
config.isUnitDisabled(UnitType.HydrogenBomb) &&
|
||||
config.isUnitDisabled(UnitType.MIRV);
|
||||
this.allDisabled = BuildMenus.types.every((u) => config.isUnitDisabled(u));
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private cost(item: UnitType): Gold {
|
||||
for (const bu of this.playerActions?.buildableUnits ?? []) {
|
||||
for (const bu of this.playerBuildables ?? []) {
|
||||
if (bu.type === item) {
|
||||
return bu.cost;
|
||||
}
|
||||
@@ -101,10 +97,10 @@ export class UnitDisplay extends LitElement implements Layer {
|
||||
|
||||
tick() {
|
||||
const player = this.game?.myPlayer();
|
||||
player?.actions().then((actions) => {
|
||||
this.playerActions = actions;
|
||||
});
|
||||
if (!player) return;
|
||||
player.buildables(undefined, BuildMenus.types).then((buildables) => {
|
||||
this.playerBuildables = buildables;
|
||||
});
|
||||
this._cities = player.totalUnitLevels(UnitType.City);
|
||||
this._missileSilo = player.totalUnitLevels(UnitType.MissileSilo);
|
||||
this._port = player.totalUnitLevels(UnitType.Port);
|
||||
@@ -218,7 +214,7 @@ export class UnitDisplay extends LitElement implements Layer {
|
||||
private renderUnitItem(
|
||||
icon: string,
|
||||
number: number | null,
|
||||
unitType: UnitType,
|
||||
unitType: PlayerBuildableUnitType,
|
||||
structureKey: string,
|
||||
hotkey: string,
|
||||
) {
|
||||
|
||||
@@ -65,11 +65,27 @@ export class UnitLayer implements Layer {
|
||||
}
|
||||
|
||||
tick() {
|
||||
const unitIds = this.game
|
||||
.updatesSinceLastTick()
|
||||
?.[GameUpdateType.Unit]?.map((unit) => unit.id);
|
||||
const updatedUnitIds =
|
||||
this.game
|
||||
.updatesSinceLastTick()
|
||||
?.[GameUpdateType.Unit]?.map((unit) => unit.id) ?? [];
|
||||
|
||||
this.updateUnitsSprites(unitIds ?? []);
|
||||
const motionPlanUnitIds = this.game.motionPlannedUnitIds();
|
||||
|
||||
if (updatedUnitIds.length === 0) {
|
||||
this.updateUnitsSprites(motionPlanUnitIds);
|
||||
return;
|
||||
}
|
||||
if (motionPlanUnitIds.length === 0) {
|
||||
this.updateUnitsSprites(updatedUnitIds);
|
||||
return;
|
||||
}
|
||||
|
||||
const unitIds = new Set<number>(updatedUnitIds);
|
||||
for (const id of motionPlanUnitIds) {
|
||||
unitIds.add(id);
|
||||
}
|
||||
this.updateUnitsSprites(Array.from(unitIds));
|
||||
}
|
||||
|
||||
init() {
|
||||
@@ -146,6 +162,10 @@ export class UnitLayer implements Layer {
|
||||
event.y,
|
||||
);
|
||||
|
||||
if (!this.game.isValidCoord(cell.x, cell.y)) {
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
patternRelationship,
|
||||
} from "../../Cosmetics";
|
||||
import { crazyGamesSDK } from "../../CrazyGamesSDK";
|
||||
import { Platform } from "../../Platform";
|
||||
import { SendWinnerEvent } from "../../Transport";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
@@ -61,7 +62,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 animate-fadeIn"
|
||||
? "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"
|
||||
: "hidden"}"
|
||||
>
|
||||
<h2 class="m-0 mb-4 text-[26px] text-center text-white">
|
||||
@@ -93,29 +94,12 @@ export class WinModal extends LitElement implements Layer {
|
||||
@click=${this.hide}
|
||||
class="flex-1 px-3 py-3 text-base cursor-pointer bg-blue-500/60 text-white border-0 rounded-sm transition-all duration-200 hover:bg-blue-500/80 hover:-translate-y-px active:translate-y-px"
|
||||
>
|
||||
${this.game.myPlayer()?.isAlive()
|
||||
${this.game?.myPlayer()?.isAlive()
|
||||
? translateText("win_modal.keep")
|
||||
: translateText("win_modal.spectate")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -48%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -203,8 +187,7 @@ export class WinModal extends LitElement implements Layer {
|
||||
|
||||
// Shuffle the array and take patterns based on screen size
|
||||
const shuffled = [...purchasablePatterns].sort(() => Math.random() - 0.5);
|
||||
const isMobile = window.innerWidth < 768; // md breakpoint
|
||||
const maxPatterns = isMobile ? 1 : 3;
|
||||
const maxPatterns = Platform.isMobileWidth ? 1 : 3;
|
||||
const selectedPatterns = shuffled.slice(
|
||||
0,
|
||||
Math.min(maxPatterns, shuffled.length),
|
||||
|
||||
+25
-2
@@ -57,8 +57,24 @@ body {
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
|
||||
}
|
||||
|
||||
/* Hide scrollbar on mobile, show on larger screens */
|
||||
@media (max-width: 1023px) {
|
||||
* {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
* {
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* Add custom scrollbar styles */
|
||||
@@ -66,6 +82,13 @@ body {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
|
||||
@@ -6,22 +6,28 @@
|
||||
--boxBackgroundColor: #111827cc;
|
||||
--fontColor: #202020;
|
||||
--fontColorLight: #fff;
|
||||
--primaryColor: #2563eb;
|
||||
--primaryColorHover: #1d4ed8;
|
||||
|
||||
/* Palette: Deep French Blue / Muted Cyan / Black / Forest Teal */
|
||||
--frenchBlue: #1f3a70; /* Deeper French Blue */
|
||||
--cyanBlue: #0f6ca3; /* Muted Cyan secondary */
|
||||
--tealAccent: #1f6c5a; /* Darker Teal accent */
|
||||
|
||||
--primaryColor: var(--frenchBlue);
|
||||
--primaryColorHover: var(--tealAccent);
|
||||
--primaryColorDisabled: linear-gradient(
|
||||
to right,
|
||||
rgb(74, 74, 74),
|
||||
rgb(61, 61, 61)
|
||||
);
|
||||
--secondaryColor: #dbeafe;
|
||||
--secondaryColorHover: #bfdbfe;
|
||||
--secondaryColor: var(--cyanBlue);
|
||||
--secondaryColorHover: var(--cyanBlue);
|
||||
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
--primaryColorDark: #3b82f6;
|
||||
--primaryColorHoverDark: #2563eb;
|
||||
--primaryColorDark: var(--frenchBlue);
|
||||
--primaryColorHoverDark: var(--tealAccent);
|
||||
--primaryColorDisabledDark: #4b5563;
|
||||
--secondaryColorDark: #374151;
|
||||
--secondaryColorHoverDark: #4b5563;
|
||||
--secondaryColorDark: var(--tealAccent);
|
||||
--secondaryColorHoverDark: var(--frenchBlue);
|
||||
--fontColorDark: #f3f4f6;
|
||||
|
||||
/* Achievements */
|
||||
|
||||
@@ -32,8 +32,4 @@
|
||||
.l-header__highlightText {
|
||||
color: #2563eb;
|
||||
font-weight: 700;
|
||||
filter: drop-shadow(1px 1px 0px rgb(255, 255, 255))
|
||||
drop-shadow(-1px -1px 0px rgb(255, 255, 255))
|
||||
drop-shadow(1px -1px 0px rgb(255, 255, 255))
|
||||
drop-shadow(-1px 1px 0px rgb(255, 255, 255));
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Platform } from "../Platform";
|
||||
|
||||
export type RendererType = "Canvas2D" | "WebGL1" | "WebGL2";
|
||||
|
||||
export interface BrowserInfo {
|
||||
@@ -48,7 +50,7 @@ export async function collectGraphicsDiagnostics(
|
||||
|
||||
const uaData = (navigator as any).userAgentData;
|
||||
|
||||
const os = uaData?.platform ?? detectOS(navigator.userAgent);
|
||||
const os = Platform.os;
|
||||
|
||||
const browser: BrowserInfo = {
|
||||
engine: uaData?.brands
|
||||
@@ -130,12 +132,3 @@ export async function collectGraphicsDiagnostics(
|
||||
power,
|
||||
};
|
||||
}
|
||||
|
||||
function detectOS(ua: string): string {
|
||||
if (/windows nt/i.test(ua)) return "Windows";
|
||||
if (/mac os x/i.test(ua)) return "macOS";
|
||||
if (/android/i.test(ua)) return "Android";
|
||||
if (/iphone|ipad|ipod/i.test(ua)) return "iOS";
|
||||
if (/linux/i.test(ua)) return "Linux";
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { GameMapType, UnitType } from "../../core/game/Game";
|
||||
import { GameConfig } from "../../core/Schemas";
|
||||
|
||||
/**
|
||||
* Maps a slider value (0-400) to the nations config value.
|
||||
* 0 → "disabled", value === defaultNationCount → "default", otherwise → number.
|
||||
*/
|
||||
export function sliderToNationsConfig(
|
||||
sliderValue: number,
|
||||
defaultNationCount: number,
|
||||
): GameConfig["nations"] {
|
||||
if (sliderValue === 0) return "disabled";
|
||||
if (sliderValue === defaultNationCount) return "default";
|
||||
return sliderValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a nations config value to a slider-friendly number.
|
||||
* "disabled" → 0, "default" → defaultNationCount, number → number.
|
||||
*/
|
||||
export function nationsConfigToSlider(
|
||||
nations: GameConfig["nations"],
|
||||
defaultNationCount: number,
|
||||
): number {
|
||||
if (nations === "disabled") return 0;
|
||||
if (nations === "default") return defaultNationCount;
|
||||
return nations;
|
||||
}
|
||||
|
||||
export function toOptionalNumber(
|
||||
value: number | string | undefined,
|
||||
): number | undefined {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const numeric = Number(trimmed);
|
||||
return Number.isFinite(numeric) ? numeric : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function preventDisallowedKeys(
|
||||
e: KeyboardEvent,
|
||||
disallowedKeys: string[],
|
||||
): void {
|
||||
if (disallowedKeys.includes(e.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
export function parseBoundedIntegerFromInput(
|
||||
input: HTMLInputElement,
|
||||
{
|
||||
min,
|
||||
max,
|
||||
stripPattern = /[eE+-]/g,
|
||||
radix = 10,
|
||||
}: {
|
||||
min: number;
|
||||
max: number;
|
||||
stripPattern?: RegExp;
|
||||
radix?: number;
|
||||
},
|
||||
): number | undefined {
|
||||
input.value = input.value.replace(stripPattern, "");
|
||||
const value = parseInt(input.value, radix);
|
||||
|
||||
if (isNaN(value) || value < min || value > max) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseBoundedFloatFromInput(
|
||||
input: HTMLInputElement,
|
||||
{ min, max }: { min: number; max: number },
|
||||
): number | undefined {
|
||||
const value = parseFloat(input.value);
|
||||
|
||||
if (isNaN(value) || value < min || value > max) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getBotsForCompactMap(
|
||||
bots: number,
|
||||
compactMapEnabled: boolean,
|
||||
): number {
|
||||
if (compactMapEnabled && bots === 400) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
if (!compactMapEnabled && bots === 100) {
|
||||
return 400;
|
||||
}
|
||||
|
||||
return bots;
|
||||
}
|
||||
|
||||
export function getNationsForCompactMap(
|
||||
nations: number,
|
||||
defaultNationCount: number,
|
||||
compactMapEnabled: boolean,
|
||||
): number {
|
||||
const compactCount = Math.max(0, Math.floor(defaultNationCount * 0.25));
|
||||
if (compactMapEnabled) {
|
||||
// Only reduce if at the full default
|
||||
if (nations === defaultNationCount) {
|
||||
return compactCount;
|
||||
}
|
||||
return nations;
|
||||
}
|
||||
// Restoring from compact: if at the compact default, go back to full default
|
||||
if (nations === compactCount) {
|
||||
return defaultNationCount;
|
||||
}
|
||||
return nations;
|
||||
}
|
||||
|
||||
export function getRandomMapType(): GameMapType {
|
||||
const maps = Object.values(GameMapType);
|
||||
const randIdx = Math.floor(Math.random() * maps.length);
|
||||
return maps[randIdx] as GameMapType;
|
||||
}
|
||||
|
||||
export function getUpdatedDisabledUnits(
|
||||
disabledUnits: UnitType[],
|
||||
unit: UnitType,
|
||||
checked: boolean,
|
||||
): UnitType[] {
|
||||
return checked
|
||||
? [...disabledUnits, unit]
|
||||
: disabledUnits.filter((u) => u !== unit);
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import { TemplateResult, html, nothing } from "lit";
|
||||
import { translateText } from "../Utils";
|
||||
|
||||
export const TOGGLE_INPUT_CARD_CLASSES = {
|
||||
containerActive:
|
||||
"bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]",
|
||||
containerInactive:
|
||||
"bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 opacity-80",
|
||||
labelBase:
|
||||
"text-[10px] uppercase font-bold tracking-wider text-center w-full leading-tight break-words hyphens-auto",
|
||||
labelActive: "text-white",
|
||||
labelInactive: "text-white/60",
|
||||
input:
|
||||
"w-full text-center rounded bg-black/60 text-white text-sm font-bold border border-white/20 focus:outline-none focus:border-blue-500 p-1 my-1",
|
||||
};
|
||||
|
||||
export interface ToggleInputCardInputOptions {
|
||||
id?: string;
|
||||
type?: string;
|
||||
min?: number | string;
|
||||
max?: number | string;
|
||||
step?: number | string;
|
||||
value?: number | string;
|
||||
ariaLabel?: string;
|
||||
placeholder?: string;
|
||||
onInput?: (e: Event) => void;
|
||||
onChange?: (e: Event) => void;
|
||||
onKeyDown?: (e: KeyboardEvent) => void;
|
||||
onClick?: (e: Event) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function renderToggleInputCardInput({
|
||||
id,
|
||||
type = "number",
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
value,
|
||||
ariaLabel,
|
||||
placeholder,
|
||||
onInput,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
onClick,
|
||||
className = TOGGLE_INPUT_CARD_CLASSES.input,
|
||||
}: ToggleInputCardInputOptions): TemplateResult {
|
||||
const resolvedValue = value ?? "";
|
||||
const handleClick = onClick ?? ((e: Event) => e.stopPropagation());
|
||||
|
||||
return html`
|
||||
<input
|
||||
type=${type}
|
||||
id=${id ?? nothing}
|
||||
min=${min ?? nothing}
|
||||
max=${max ?? nothing}
|
||||
step=${step ?? nothing}
|
||||
.value=${String(resolvedValue)}
|
||||
class=${className}
|
||||
aria-label=${ariaLabel ?? nothing}
|
||||
placeholder=${placeholder ?? nothing}
|
||||
@click=${handleClick}
|
||||
@input=${onInput}
|
||||
@change=${onChange}
|
||||
@keydown=${onKeyDown}
|
||||
/>
|
||||
`;
|
||||
}
|
||||
|
||||
export interface ToggleInputCardRenderContext {
|
||||
labelKey: string;
|
||||
checked: boolean;
|
||||
input?: TemplateResult;
|
||||
onClick?: (e: Event) => void;
|
||||
onKeyDown?: (e: KeyboardEvent) => void;
|
||||
activeClassName?: string;
|
||||
inactiveClassName?: string;
|
||||
labelBaseClassName?: string;
|
||||
labelActiveClassName?: string;
|
||||
labelInactiveClassName?: string;
|
||||
role?: string;
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
export function renderToggleInputCard({
|
||||
labelKey,
|
||||
checked,
|
||||
input,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
activeClassName = TOGGLE_INPUT_CARD_CLASSES.containerActive,
|
||||
inactiveClassName = TOGGLE_INPUT_CARD_CLASSES.containerInactive,
|
||||
labelBaseClassName = TOGGLE_INPUT_CARD_CLASSES.labelBase,
|
||||
labelActiveClassName = TOGGLE_INPUT_CARD_CLASSES.labelActive,
|
||||
labelInactiveClassName = TOGGLE_INPUT_CARD_CLASSES.labelInactive,
|
||||
role,
|
||||
tabIndex,
|
||||
}: ToggleInputCardRenderContext): TemplateResult {
|
||||
const shouldBehaveLikeButton = Boolean(onClick ?? onKeyDown);
|
||||
const resolvedRole = role ?? (shouldBehaveLikeButton ? "button" : undefined);
|
||||
const resolvedTabIndex = tabIndex ?? (shouldBehaveLikeButton ? 0 : undefined);
|
||||
const resolvedOnKeyDown =
|
||||
onKeyDown ??
|
||||
(onClick
|
||||
? (e: KeyboardEvent) => {
|
||||
if ((e.target as HTMLElement).tagName.toLowerCase() === "input") {
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onClick(e);
|
||||
}
|
||||
}
|
||||
: undefined);
|
||||
|
||||
return html`
|
||||
<div
|
||||
role=${resolvedRole ?? nothing}
|
||||
tabindex=${resolvedTabIndex ?? nothing}
|
||||
@click=${onClick}
|
||||
@keydown=${resolvedOnKeyDown}
|
||||
class="relative p-3 rounded-xl border transition-all duration-200 flex flex-col items-center justify-between gap-2 h-full cursor-pointer min-h-[100px] ${checked
|
||||
? activeClassName
|
||||
: inactiveClassName}"
|
||||
>
|
||||
<div class="flex items-center justify-center w-full mt-1">
|
||||
<div
|
||||
class="w-5 h-5 rounded border flex items-center justify-center transition-colors ${checked
|
||||
? "bg-blue-500 border-blue-500"
|
||||
: "border-white/20 bg-white/5"}"
|
||||
>
|
||||
${checked
|
||||
? html`<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-3 w-3 text-white"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${checked
|
||||
? (input ?? html``)
|
||||
: html`<div class="h-[2px] w-4 bg-white/10 rounded my-3"></div>`}
|
||||
|
||||
<div
|
||||
class="${labelBaseClassName} ${checked
|
||||
? labelActiveClassName
|
||||
: labelInactiveClassName}"
|
||||
>
|
||||
${translateText(labelKey)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { html, TemplateResult } from "lit";
|
||||
import { UnitType } from "../../core/game/Game";
|
||||
import { translateText } from "../Utils";
|
||||
|
||||
export interface UnitTypeRenderContext {
|
||||
disabledUnits: UnitType[];
|
||||
toggleUnit: (unit: UnitType, checked: boolean) => void;
|
||||
}
|
||||
|
||||
const unitOptions: { type: UnitType; translationKey: string }[] = [
|
||||
{ type: UnitType.City, translationKey: "unit_type.city" },
|
||||
{ type: UnitType.DefensePost, translationKey: "unit_type.defense_post" },
|
||||
{ type: UnitType.Port, translationKey: "unit_type.port" },
|
||||
{ type: UnitType.Warship, translationKey: "unit_type.warship" },
|
||||
{ type: UnitType.MissileSilo, translationKey: "unit_type.missile_silo" },
|
||||
{ type: UnitType.SAMLauncher, translationKey: "unit_type.sam_launcher" },
|
||||
{ type: UnitType.AtomBomb, translationKey: "unit_type.atom_bomb" },
|
||||
{ type: UnitType.HydrogenBomb, translationKey: "unit_type.hydrogen_bomb" },
|
||||
{ type: UnitType.MIRV, translationKey: "unit_type.mirv" },
|
||||
{ type: UnitType.Factory, translationKey: "unit_type.factory" },
|
||||
];
|
||||
|
||||
export function renderUnitTypeOptions({
|
||||
disabledUnits,
|
||||
toggleUnit,
|
||||
}: UnitTypeRenderContext): TemplateResult[] {
|
||||
return unitOptions.map(({ type, translationKey }) => {
|
||||
const isEnabled = !disabledUnits.includes(type);
|
||||
return html`
|
||||
<button
|
||||
class="relative p-4 rounded-xl border transition-all duration-200 flex flex-col items-center justify-center gap-2 min-h-[100px] w-full cursor-pointer ${isEnabled
|
||||
? "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
|
||||
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 opacity-80"}"
|
||||
aria-pressed="${isEnabled}"
|
||||
@click=${() => toggleUnit(type, isEnabled)}
|
||||
>
|
||||
<div
|
||||
class="text-xs uppercase font-bold tracking-wider text-center w-full leading-tight break-words hyphens-auto ${isEnabled
|
||||
? "text-white"
|
||||
: "text-white/60"}"
|
||||
>
|
||||
${translateText(translationKey)}
|
||||
</div>
|
||||
</button>
|
||||
`;
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { base64urlToUuid } from "./Base64";
|
||||
import { BigIntStringSchema, PlayerStatsSchema } from "./StatsSchemas";
|
||||
import { Difficulty, GameMapType, GameMode, GameType } from "./game/Game";
|
||||
import { Difficulty, GameMode, GameType, RankedType } from "./game/Game";
|
||||
|
||||
export const RefreshResponseSchema = z.object({
|
||||
token: z.string(),
|
||||
@@ -43,7 +43,7 @@ export const DiscordUserSchema = z.object({
|
||||
export type DiscordUser = z.infer<typeof DiscordUserSchema>;
|
||||
|
||||
const SingleplayerMapAchievementSchema = z.object({
|
||||
mapName: z.enum(GameMapType),
|
||||
mapName: z.string(),
|
||||
difficulty: z.enum(Difficulty),
|
||||
});
|
||||
|
||||
@@ -99,7 +99,7 @@ export const PlayerGameSchema = z.object({
|
||||
start: z.iso.datetime(),
|
||||
mode: z.enum(GameMode),
|
||||
type: z.enum(GameType),
|
||||
map: z.enum(GameMapType),
|
||||
map: z.string(),
|
||||
difficulty: z.enum(Difficulty),
|
||||
clientId: z.string().optional(),
|
||||
});
|
||||
@@ -174,7 +174,7 @@ export type RankedLeaderboardEntry = z.infer<
|
||||
>;
|
||||
|
||||
export const RankedLeaderboardResponseSchema = z.object({
|
||||
"1v1": RankedLeaderboardEntrySchema.array(),
|
||||
[RankedType.OneVOne]: RankedLeaderboardEntrySchema.array(),
|
||||
});
|
||||
export type RankedLeaderboardResponse = z.infer<
|
||||
typeof RankedLeaderboardResponseSchema
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user