Merge branch 'main' into embeddedurlfix

This commit is contained in:
Ryan
2026-03-06 16:45:26 +00:00
committed by GitHub
308 changed files with 17928 additions and 6135 deletions
+14 -16
View File
@@ -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(),
+13
View File
@@ -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", {
+24 -34
View File
@@ -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
View File
@@ -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;
}
+4 -3
View File
@@ -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";
+3 -4
View File
@@ -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) => {
+3 -6
View File
@@ -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);
}
+469
View File
@@ -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 "";
}
}
+6 -34
View File
@@ -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;
+4 -4
View File
@@ -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
View File
@@ -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)}
File diff suppressed because it is too large Load Diff
+28 -8
View File
@@ -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));
}
+35 -52
View File
@@ -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");
+4 -3
View File
@@ -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>
`;
+3 -3
View File
@@ -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">
+5 -3
View File
@@ -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();
}
+7 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}
}
+27 -6
View File
@@ -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");
}
+30
View File
@@ -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
View File
@@ -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
View File
@@ -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>`
+112
View File
@@ -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;
},
};
})();
-240
View File
@@ -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,
}),
);
}
}
File diff suppressed because it is too large Load Diff
+17 -24
View File
@@ -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"
+1 -5
View File
@@ -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
View File
@@ -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,
});
}
+2 -6
View File
@@ -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
+56 -10
View File
@@ -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
+5 -4
View File
@@ -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
View File
@@ -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 {
+67 -1
View File
@@ -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>
`;
}
}
+40 -53
View File
@@ -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"
+10 -1
View File
@@ -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>
+12 -8
View File
@@ -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"
+480
View File
@@ -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 -35
View File
@@ -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;
+2 -2
View File
@@ -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>
+56 -19
View File
@@ -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();
};
}
+2 -1
View File
@@ -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
View File
@@ -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>
`;
}
+179
View File
@@ -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"));
}
}
+173
View File
@@ -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"
+28 -4
View File
@@ -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"
+21 -14
View File
@@ -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>
+2 -2
View File
@@ -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>) =>
+68 -8
View File
@@ -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);
}
}
+2 -2
View File
@@ -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();
+4 -2
View File
@@ -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>
`;
}
}
+14 -18
View File
@@ -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();
}
+24 -26
View File
@@ -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();
}
}
+45 -310
View File
@@ -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 (
+29 -15
View File
@@ -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)"
>
&nbsp;${this.getTranslatedPlayerTeamLabel()} &#10687;
&nbsp;${getTranslatedPlayerTeamLabel(this.playerTeam)}
&#10687;
</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
+23 -1
View File
@@ -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()}
+49 -6
View File
@@ -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()}
+35 -17
View File
@@ -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;
}
+40 -11
View File
@@ -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();
+1 -1
View File
@@ -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) : ""}
+5 -8
View File
@@ -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>
+294 -36
View File
@@ -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
}
+109 -70
View File
@@ -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,
+65 -15
View File
@@ -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) {
+13 -13
View File
@@ -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],
];
}
// ⌞
+1 -1
View File
@@ -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">
+39 -30
View File
@@ -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;
}
}
}
+13 -9
View File
@@ -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>
+16 -20
View File
@@ -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,
) {
+24 -4
View File
@@ -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
+4 -21
View File
@@ -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
View File
@@ -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;
+14 -8
View File
@@ -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 */
-4
View File
@@ -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));
}
+3 -10
View File
@@ -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";
}
+140
View File
@@ -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>
`;
});
}
+4 -4
View File
@@ -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